mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +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') }}
|
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||||
restore-keys: android-sdk-${{ runner.os }}-
|
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
|
- name: Cache native CMake build outputs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -50,7 +60,7 @@ jobs:
|
|||||||
android/app/build/intermediates/cxx
|
android/app/build/intermediates/cxx
|
||||||
android/app/build/intermediates/merged_native_libs
|
android/app/build/intermediates/merged_native_libs
|
||||||
android/app/build/intermediates/stripped_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 }}-
|
restore-keys: cxx-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ import {
|
|||||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||||
import ComposeScreen from './src/screens/ComposeScreen';
|
import ComposeScreen from './src/screens/ComposeScreen';
|
||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
|
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
import LoginScreen from './src/screens/LoginScreen';
|
||||||
import EmailListScreen from './src/screens/EmailListScreen';
|
import EmailListScreen from './src/screens/EmailListScreen';
|
||||||
import FilesScreen from './src/screens/FilesScreen';
|
import FilesScreen from './src/screens/FilesScreen';
|
||||||
import CalendarScreen from './src/screens/CalendarScreenNew';
|
import CalendarScreen from './src/screens/CalendarScreen';
|
||||||
import ContactsScreen from './src/screens/ContactsScreenNew';
|
import ContactsScreen from './src/screens/ContactsScreen';
|
||||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
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 { useAccountStore } from './src/stores/account-store';
|
||||||
import { useAuthStore } from './src/stores/auth-store';
|
import { useAuthStore } from './src/stores/auth-store';
|
||||||
import { useCalendarStore } from './src/stores/calendar-store';
|
import { useCalendarStore } from './src/stores/calendar-store';
|
||||||
@@ -325,6 +326,7 @@ export default function App() {
|
|||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||||
|
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Compose"
|
name="Compose"
|
||||||
component={ComposeScreen}
|
component={ComposeScreen}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
<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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<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.
|
# Use this property to specify which architecture you want to build.
|
||||||
# You can also override it from the CLI using
|
# You can also override it from the CLI using
|
||||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
# ./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.
|
# Use this property to enable support to the new architecture.
|
||||||
# This will allow you to use TurboModules and the Fabric render in
|
# This will allow you to use TurboModules and the Fabric render in
|
||||||
|
|||||||
Generated
+13
-2
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "1.0.0",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "1.0.0",
|
"version": "0.1.7",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-community/datetimepicker": "8.4.4",
|
"@react-native-community/datetimepicker": "8.4.4",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~17.0.10",
|
||||||
|
"expo-intent-launcher": "~13.0.8",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sharing": "~14.0.8",
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
@@ -5234,6 +5236,15 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
"version": "3.0.24",
|
"version": "3.0.24",
|
||||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.2",
|
"version": "0.1.7",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"bump": "node scripts/bump-version.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~17.0.10",
|
||||||
|
"expo-intent-launcher": "~13.0.8",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-sharing": "~14.0.8",
|
"expo-sharing": "~14.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"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;
|
onLongPressDate?: (date: Date) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonthView({
|
function MonthViewInner({
|
||||||
currentDate,
|
currentDate,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
@@ -118,6 +118,8 @@ export function MonthView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MonthView = React.memo(MonthViewInner);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
grid: { paddingHorizontal: spacing.sm },
|
grid: { paddingHorizontal: spacing.sm },
|
||||||
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
|
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ interface WeekViewProps {
|
|||||||
weekStartsOn?: 0 | 1;
|
weekStartsOn?: 0 | 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekView({
|
function WeekViewInner({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
calendars,
|
calendars,
|
||||||
@@ -303,6 +303,8 @@ export function WeekView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const WeekView = React.memo(WeekViewInner);
|
||||||
|
|
||||||
function minutesToTimeLabel(minutes: number): string {
|
function minutesToTimeLabel(minutes: number): string {
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
const m = 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.' },
|
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdvancedSettings() {
|
export function AboutDataSettings() {
|
||||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||||
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
|
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
|
||||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
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 externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||||
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
|
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||||
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||||
@@ -4,6 +4,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
|||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
||||||
import { useUpdatesStore } from '../../stores/updates-store';
|
import { useUpdatesStore } from '../../stores/updates-store';
|
||||||
|
import type { InstallProgress } from '../../lib/install-update';
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
function formatTime(ts: number): string {
|
||||||
if (!ts) return 'never';
|
if (!ts) return 'never';
|
||||||
@@ -11,11 +12,37 @@ function formatTime(ts: number): string {
|
|||||||
return date.toLocaleString();
|
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() {
|
export function UpdatesSettings() {
|
||||||
const hydrated = useUpdatesStore((s) => s.hydrated);
|
const hydrated = useUpdatesStore((s) => s.hydrated);
|
||||||
const hydrate = useUpdatesStore((s) => s.hydrate);
|
const hydrate = useUpdatesStore((s) => s.hydrate);
|
||||||
const checking = useUpdatesStore((s) => s.checking);
|
const checking = useUpdatesStore((s) => s.checking);
|
||||||
const installing = useUpdatesStore((s) => s.installing);
|
const installing = useUpdatesStore((s) => s.installing);
|
||||||
|
const installProgress = useUpdatesStore((s) => s.installProgress);
|
||||||
const error = useUpdatesStore((s) => s.error);
|
const error = useUpdatesStore((s) => s.error);
|
||||||
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt);
|
||||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||||
@@ -81,7 +108,7 @@ export function UpdatesSettings() {
|
|||||||
onPress={onInstall}
|
onPress={onInstall}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
>
|
>
|
||||||
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
|
{installButtonLabel(installing, installProgress, apkAsset)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -95,6 +122,29 @@ export function UpdatesSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingItem>
|
</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 ? (
|
{cachedLatest?.body ? (
|
||||||
<View style={styles.notesBox}>
|
<View style={styles.notesBox}>
|
||||||
<Text style={styles.notesTitle}>Release notes</Text>
|
<Text style={styles.notesTitle}>Release notes</Text>
|
||||||
@@ -121,4 +171,20 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
||||||
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
|
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 { Platform } from 'react-native';
|
||||||
import { File, Paths } from 'expo-file-system';
|
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';
|
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') {
|
if (Platform.OS !== 'android') {
|
||||||
throw new Error('APK install is only supported on Android');
|
throw new Error('APK install is only supported on Android');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dest = new File(Paths.cache, asset.name);
|
const dest = new File(Paths.cache, asset.name);
|
||||||
if (dest.exists) dest.delete();
|
if (dest.exists) dest.delete();
|
||||||
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
|
|
||||||
if (!(await Sharing.isAvailableAsync())) {
|
onProgress?.({ phase: 'downloading', progress: 0 });
|
||||||
throw new Error('Sharing is not available on this device');
|
|
||||||
|
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',
|
onProgress?.({ phase: 'installing' });
|
||||||
dialogTitle: 'Install update',
|
|
||||||
UTI: 'com.android.package-archive',
|
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 = {
|
export type RootStackParamList = {
|
||||||
MainTabs: undefined;
|
MainTabs: undefined;
|
||||||
EmailThread: { emailId: string; threadId: string; subject?: string };
|
EmailThread: { emailId: string; threadId: string; subject?: string };
|
||||||
|
EmailSource: { emailId: string; blobId: string; subject?: string };
|
||||||
Compose:
|
Compose:
|
||||||
| {
|
| {
|
||||||
mode?: 'reply' | 'replyAll' | 'forward';
|
mode?: 'reply' | 'replyAll' | 'forward';
|
||||||
|
|||||||
+499
-118
@@ -1,165 +1,546 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
|
import {
|
||||||
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react-native';
|
View,
|
||||||
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isToday, addMonths, subMonths } from 'date-fns';
|
Text,
|
||||||
import { colors } from '../theme/colors';
|
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 = [
|
type ViewMode = 'month' | 'week' | 'agenda';
|
||||||
{ id: '1', title: 'Team Standup', time: '09:00', color: '#3b82f6' },
|
type PendingAction =
|
||||||
{ id: '2', title: 'Design Review', time: '11:00', color: '#8b5cf6' },
|
| { kind: 'edit'; event: CalendarEvent }
|
||||||
{ id: '3', title: 'Lunch with Sarah', time: '12:30', color: '#22c55e' },
|
| { kind: 'delete'; event: CalendarEvent }
|
||||||
{ id: '4', title: 'Sprint Planning', time: '14:00', color: '#f59e0b' },
|
| null;
|
||||||
];
|
|
||||||
|
|
||||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const AGENDA_DAYS = 30;
|
||||||
|
const RANGE_BUFFER_DAYS = 14;
|
||||||
|
|
||||||
function MonthGrid({ currentDate }: { currentDate: Date }) {
|
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
|
||||||
const monthStart = startOfMonth(currentDate);
|
if (viewMode === 'month') {
|
||||||
const monthEnd = endOfMonth(currentDate);
|
const start = startOfWeek(startOfMonth(currentDate));
|
||||||
const calStart = startOfWeek(monthStart);
|
const end = endOfWeek(endOfMonth(currentDate));
|
||||||
const calEnd = endOfWeek(monthEnd);
|
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||||
|
|
||||||
const days: Date[] = [];
|
|
||||||
let day = calStart;
|
|
||||||
while (day <= calEnd) {
|
|
||||||
days.push(day);
|
|
||||||
day = addDays(day, 1);
|
|
||||||
}
|
}
|
||||||
|
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 (
|
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
|
||||||
<View>
|
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
|
||||||
<View style={styles.weekdayRow}>
|
if (viewMode === 'week') {
|
||||||
{WEEKDAYS.map((wd) => (
|
const start = startOfWeek(currentDate);
|
||||||
<Text key={wd} style={styles.weekdayLabel}>{wd}</Text>
|
const end = endOfWeek(currentDate);
|
||||||
))}
|
if (start.getMonth() === end.getMonth()) {
|
||||||
</View>
|
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
|
||||||
<View style={styles.daysGrid}>
|
}
|
||||||
{days.map((d, i) => {
|
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||||
const sameMonth = isSameMonth(d, currentDate);
|
}
|
||||||
const today = isToday(d);
|
return format(currentDate, 'MMMM yyyy');
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarScreen() {
|
export default function CalendarScreen() {
|
||||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
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 (
|
return (
|
||||||
<ScrollView style={styles.container}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<CalendarDays size={24} color={colors.primary} />
|
<Pressable
|
||||||
<Text style={styles.headerTitle}>Calendar</Text>
|
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>
|
||||||
|
|
||||||
<View style={styles.monthNav}>
|
<View style={styles.nav}>
|
||||||
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
|
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||||
<ChevronLeft size={20} color={colors.text} />
|
<ChevronLeft size={20} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.monthLabel}>{format(currentDate, 'MMMM yyyy')}</Text>
|
<Button variant="outline" size="sm" onPress={goToday}>
|
||||||
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
|
Today
|
||||||
|
</Button>
|
||||||
|
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||||
<ChevronRight size={20} color={colors.text} />
|
<ChevronRight size={20} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<MonthGrid currentDate={currentDate} />
|
{error && (
|
||||||
|
<View style={styles.errorBanner}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.todaySection}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.todaySectionTitle}>Today's Events</Text>
|
{viewMode === 'month' && (
|
||||||
{MOCK_EVENTS.map((event) => (
|
<MonthView
|
||||||
<Pressable key={event.id} style={styles.eventCard}>
|
currentDate={currentDate}
|
||||||
<View style={[styles.eventDot, { backgroundColor: event.color }]} />
|
selectedDate={selectedDate}
|
||||||
<View style={styles.eventContent}>
|
events={events}
|
||||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
eventsByDay={eventsByDay}
|
||||||
<Text style={styles.eventTime}>{event.time}</Text>
|
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>
|
</View>
|
||||||
</Pressable>
|
<DayEventList
|
||||||
))}
|
date={selectedDate}
|
||||||
|
eventsByDay={eventsByDay}
|
||||||
|
calendars={calendars}
|
||||||
|
onSelectEvent={setDetailEvent}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
container: { flex: 1, backgroundColor: colors.background },
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 12,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
gap: spacing.sm,
|
||||||
borderBottomColor: colors.border,
|
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
headerBtn: {
|
||||||
monthNav: {
|
width: 36,
|
||||||
flexDirection: 'row',
|
height: 36,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 16,
|
borderRadius: radius.full,
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
},
|
||||||
navBtn: { padding: 8 },
|
headerLeft: { flex: 1 },
|
||||||
monthLabel: { fontSize: 17, fontWeight: '600', color: colors.text },
|
headerTitle: { ...typography.h3, color: colors.text },
|
||||||
weekdayRow: {
|
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||||
|
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
|
viewToggle: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 8,
|
backgroundColor: colors.surface,
|
||||||
marginBottom: 4,
|
borderRadius: radius.md,
|
||||||
|
padding: 2,
|
||||||
},
|
},
|
||||||
weekdayLabel: {
|
viewToggleBtn: {
|
||||||
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: {
|
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 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',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
dayText: { fontSize: 14, color: colors.text },
|
|
||||||
dayTextMuted: { color: colors.textMuted },
|
nav: {
|
||||||
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: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.surface,
|
justifyContent: 'center',
|
||||||
borderRadius: 10,
|
paddingVertical: spacing.xs,
|
||||||
padding: 12,
|
gap: spacing.lg,
|
||||||
marginBottom: 8,
|
|
||||||
},
|
},
|
||||||
eventDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
|
navBtn: {
|
||||||
eventContent: { flex: 1 },
|
width: 36,
|
||||||
eventTitle: { fontSize: 15, fontWeight: '500', color: colors.text },
|
height: 36,
|
||||||
eventTime: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
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 React from 'react';
|
||||||
import { View, Text, StyleSheet, SectionList, Pressable } from 'react-native';
|
import {
|
||||||
import { Users } from 'lucide-react-native';
|
View,
|
||||||
import { colors } from '../theme/colors';
|
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 = [
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function groupByLetter(contacts: typeof MOCK_CONTACTS) {
|
interface Section {
|
||||||
const groups: Record<string, typeof MOCK_CONTACTS> = {};
|
title: string;
|
||||||
contacts.forEach((c) => {
|
data: ContactCard[];
|
||||||
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] }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContactRow({ item }: { item: typeof MOCK_CONTACTS[0] }) {
|
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||||
return (
|
const sorted = sortContactsByDisplayName(contacts);
|
||||||
<Pressable style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}>
|
const groups: Record<string, ContactCard[]> = {};
|
||||||
<View style={styles.avatar}>
|
for (const c of sorted) {
|
||||||
<Text style={styles.avatarText}>{item.name.charAt(0)}</Text>
|
const name = getContactDisplayName(c).trim();
|
||||||
</View>
|
const letter = (name[0] || '#').toUpperCase();
|
||||||
<View style={styles.contactInfo}>
|
const key = /[A-Z]/.test(letter) ? letter : '#';
|
||||||
<Text style={styles.contactName}>{item.name}</Text>
|
if (!groups[key]) groups[key] = [];
|
||||||
<Text style={styles.contactEmail}>{item.email}</Text>
|
groups[key].push(c);
|
||||||
</View>
|
}
|
||||||
</Pressable>
|
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() {
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Users size={24} color={colors.primary} />
|
{selectionMode ? (
|
||||||
<Text style={styles.headerTitle}>Contacts</Text>
|
<>
|
||||||
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
|
<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>
|
</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
|
<SectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={({ item }) => <ContactRow item={item} />}
|
renderItem={({ item }) => (
|
||||||
renderSectionHeader={({ section: { title } }) => (
|
<ContactListRow
|
||||||
<View style={styles.sectionHeader}>
|
contact={item}
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
onPress={() => handleRowPress(item)}
|
||||||
</View>
|
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} />}
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
|
stickySectionHeadersEnabled
|
||||||
contentContainerStyle={styles.listContent}
|
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({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
container: { flex: 1, backgroundColor: colors.background },
|
||||||
|
listContent: { paddingBottom: 80 },
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: 12,
|
paddingVertical: spacing.md,
|
||||||
borderBottomWidth: 1,
|
gap: spacing.sm,
|
||||||
borderBottomColor: colors.border,
|
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
|
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||||
headerCount: { fontSize: 13, color: colors.textSecondary },
|
headerCount: {
|
||||||
listContent: { paddingBottom: 20 },
|
...typography.small,
|
||||||
sectionHeader: {
|
color: colors.textMuted,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
paddingHorizontal: 16,
|
borderRadius: radius.full,
|
||||||
paddingVertical: 6,
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
headerActions: {
|
||||||
contactRow: {
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
gap: spacing.sm,
|
||||||
paddingVertical: 10,
|
|
||||||
},
|
},
|
||||||
contactRowPressed: { backgroundColor: colors.surfaceHover },
|
headerIconBtn: {
|
||||||
avatar: {
|
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: colors.primaryLight,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 12,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
avatarText: { fontSize: 16, fontWeight: '600', color: colors.textInverse },
|
addBtn: {
|
||||||
contactInfo: { flex: 1 },
|
width: 36,
|
||||||
contactName: { fontSize: 15, fontWeight: '500', color: colors.text },
|
height: 36,
|
||||||
contactEmail: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
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 },
|
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' });
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmailRow({
|
const EmailRow = React.memo(function EmailRow({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
@@ -51,8 +51,8 @@ function EmailRow({
|
|||||||
selectionMode,
|
selectionMode,
|
||||||
}: {
|
}: {
|
||||||
item: Email;
|
item: Email;
|
||||||
onPress: () => void;
|
onPress: (id: string) => void;
|
||||||
onLongPress: () => void;
|
onLongPress: (id: string) => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -61,6 +61,9 @@ function EmailRow({
|
|||||||
const unread = isUnread(item);
|
const unread = isUnread(item);
|
||||||
const starred = isStarred(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 (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
@@ -68,8 +71,8 @@ function EmailRow({
|
|||||||
pressed && styles.emailRowPressed,
|
pressed && styles.emailRowPressed,
|
||||||
selected && styles.emailRowSelected,
|
selected && styles.emailRowSelected,
|
||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={handlePress}
|
||||||
onLongPress={onLongPress}
|
onLongPress={handleLongPress}
|
||||||
delayLongPress={300}
|
delayLongPress={300}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
@@ -117,7 +120,11 @@ function EmailRow({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const EmailRowSeparator = () => <View style={styles.separator} />;
|
||||||
|
|
||||||
|
const emailKeyExtractor = (item: Email) => item.id;
|
||||||
|
|
||||||
interface EmailListScreenProps {
|
interface EmailListScreenProps {
|
||||||
onEmailPress?: (email: Email) => void;
|
onEmailPress?: (email: Email) => void;
|
||||||
@@ -152,6 +159,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
const allSelected =
|
const allSelected =
|
||||||
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
|
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) => {
|
const toggleSelect = React.useCallback((id: string) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(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(() => {
|
const clearSelection = React.useCallback(() => {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
@@ -488,20 +527,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={emails}
|
data={emails}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={emailKeyExtractor}
|
||||||
renderItem={({ item }) => (
|
renderItem={renderEmailRow}
|
||||||
<EmailRow
|
ItemSeparatorComponent={EmailRowSeparator}
|
||||||
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} />}
|
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
onEndReached={() => { void loadMoreEmails(); }}
|
onEndReached={() => { void loadMoreEmails(); }}
|
||||||
onEndReachedThreshold={0.3}
|
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 React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||||
Modal, useWindowDimensions, Animated, Easing,
|
Modal, useWindowDimensions, Animated, Easing, Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
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';
|
} from 'lucide-react-native';
|
||||||
import type { LucideIcon } from 'lucide-react-native';
|
import type { LucideIcon } from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||||
@@ -17,6 +17,7 @@ import EmailBodyView from '../components/EmailBodyView';
|
|||||||
import SenderAvatar from '../components/SenderAvatar';
|
import SenderAvatar from '../components/SenderAvatar';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { setEmailKeywords } from '../api/email';
|
import { setEmailKeywords } from '../api/email';
|
||||||
|
import { shareEmailEml } from '../lib/email-export';
|
||||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||||
import type { Email, Mailbox } from '../api/types';
|
import type { Email, Mailbox } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/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 moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
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 [email, setEmail] = React.useState<Email | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
@@ -342,7 +355,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<ChevronLeft size={20} color={colors.textMuted} />}
|
icon={<ChevronLeft size={20} color={colors.textMuted} />}
|
||||||
label="Prev"
|
label="Prev"
|
||||||
disabled
|
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
|
||||||
|
disabled={!prevEmail}
|
||||||
/>
|
/>
|
||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<Reply size={20} color={colors.textSecondary} />}
|
icon={<Reply size={20} color={colors.textSecondary} />}
|
||||||
@@ -362,7 +376,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<ChevronRight size={20} color={colors.textMuted} />}
|
icon={<ChevronRight size={20} color={colors.textMuted} />}
|
||||||
label="Next"
|
label="Next"
|
||||||
disabled
|
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
|
||||||
|
disabled={!nextEmail}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -372,15 +387,36 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
visible={moreMenuOpen}
|
visible={moreMenuOpen}
|
||||||
onClose={() => setMoreMenuOpen(false)}
|
onClose={() => setMoreMenuOpen(false)}
|
||||||
unread={unread}
|
unread={unread}
|
||||||
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
|
canArchive={!!archiveMailbox && currentMailboxId !== archiveMailbox?.id}
|
||||||
canMarkUnread={!showMarkUnread}
|
canMarkUnread={true}
|
||||||
canMove={mailboxes.length > 0}
|
canMove={mailboxes.length > 0}
|
||||||
showSpam={!!junkMailbox || isInJunk}
|
showSpam={!!junkMailbox || isInJunk}
|
||||||
isInJunk={isInJunk}
|
isInJunk={isInJunk}
|
||||||
|
canViewSource={!!email?.blobId}
|
||||||
|
canExport={!!email?.blobId}
|
||||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||||
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
||||||
onToggleSpam={onToggleSpam}
|
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
|
<MoveMenuSheet
|
||||||
@@ -403,15 +439,20 @@ interface MoreMenuSheetProps {
|
|||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
showSpam: boolean;
|
showSpam: boolean;
|
||||||
isInJunk: boolean;
|
isInJunk: boolean;
|
||||||
|
canViewSource: boolean;
|
||||||
|
canExport: boolean;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
onToggleUnread: () => void;
|
onToggleUnread: () => void;
|
||||||
onMove: () => void;
|
onMove: () => void;
|
||||||
onToggleSpam: () => void;
|
onToggleSpam: () => void;
|
||||||
|
onViewSource: () => void;
|
||||||
|
onExport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoreMenuSheet({
|
function MoreMenuSheet({
|
||||||
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
||||||
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
|
showSpam, isInJunk, canViewSource, canExport,
|
||||||
|
onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport,
|
||||||
}: MoreMenuSheetProps) {
|
}: MoreMenuSheetProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||||
@@ -490,6 +531,20 @@ function MoreMenuSheet({
|
|||||||
onPress={onToggleSpam}
|
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>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
+352
-107
@@ -1,143 +1,388 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, Pressable, BackHandler } from 'react-native';
|
||||||
import { Settings, User, Palette, Bell, Shield, ChevronRight } from 'lucide-react-native';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { colors } from '../theme/colors';
|
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 {
|
type Tab =
|
||||||
icon: React.ReactNode;
|
| 'account' | 'language' | 'notifications'
|
||||||
title: string;
|
| 'appearance' | 'layout'
|
||||||
subtitle?: string;
|
| 'reading' | 'composing' | 'identities' | 'vacation'
|
||||||
rightElement?: React.ReactNode;
|
| '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) {
|
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||||
return (
|
general: 'General',
|
||||||
<Pressable style={({ pressed }) => [styles.settingRow, pressed && styles.settingRowPressed]}>
|
appearance: 'Appearance',
|
||||||
<View style={styles.settingIcon}>{icon}</View>
|
mail: 'Mail',
|
||||||
<View style={styles.settingContent}>
|
privacy: 'Privacy & Security',
|
||||||
<Text style={styles.settingTitle}>{title}</Text>
|
apps: 'Apps',
|
||||||
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
|
advanced: 'Advanced',
|
||||||
</View>
|
};
|
||||||
{rightElement || <ChevronRight size={18} color={colors.textMuted} />}
|
|
||||||
</Pressable>
|
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() {
|
interface SettingsScreenProps {
|
||||||
const [darkMode, setDarkMode] = React.useState(false);
|
onLogout?: () => void;
|
||||||
const [notifications, setNotifications] = React.useState(true);
|
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 (
|
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}>
|
<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>
|
<Text style={styles.headerTitle}>Settings</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.profileCard}>
|
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||||
<View style={styles.profileAvatar}>
|
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||||
<Text style={styles.profileAvatarText}>B</Text>
|
<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>
|
||||||
<View style={styles.profileInfo}>
|
|
||||||
<Text style={styles.profileName}>Bulwark User</Text>
|
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||||
<Text style={styles.profileEmail}>user@example.com</Text>
|
<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>
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
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: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
height: componentSizes.headerHeight,
|
||||||
paddingVertical: 12,
|
paddingHorizontal: spacing.lg,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: colors.border,
|
||||||
gap: 8,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
headerBackBtn: {
|
||||||
profileCard: {
|
width: 40,
|
||||||
flexDirection: 'row',
|
height: 40,
|
||||||
alignItems: 'center',
|
|
||||||
padding: 16,
|
|
||||||
margin: 16,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderRadius: 12,
|
|
||||||
},
|
|
||||||
profileAvatar: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 16,
|
borderRadius: radius.md,
|
||||||
},
|
},
|
||||||
profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse },
|
headerBackBtnPressed: {
|
||||||
profileInfo: { flex: 1 },
|
backgroundColor: colors.accent,
|
||||||
profileName: { fontSize: 18, fontWeight: '600', color: colors.text },
|
},
|
||||||
profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 },
|
headerTitle: {
|
||||||
section: { marginTop: 8 },
|
...typography.h3,
|
||||||
sectionTitle: {
|
color: colors.text,
|
||||||
fontSize: 13,
|
},
|
||||||
|
|
||||||
|
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',
|
fontWeight: '600',
|
||||||
color: colors.textSecondary,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
textTransform: 'uppercase',
|
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
},
|
},
|
||||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
tabItemPressed: {
|
||||||
settingIcon: { marginRight: 14 },
|
backgroundColor: colors.muted,
|
||||||
settingContent: { flex: 1 },
|
},
|
||||||
settingTitle: { fontSize: 16, color: colors.text },
|
tabItemLeft: {
|
||||||
settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
flexDirection: 'row',
|
||||||
footer: {
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 32,
|
gap: spacing.md,
|
||||||
gap: 4,
|
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 Constants from 'expo-constants';
|
||||||
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
||||||
import { isNewer } from '../lib/version-compare';
|
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 STORAGE_KEY = 'webmail:updates:v1';
|
||||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
@@ -26,6 +26,7 @@ export interface UpdatesState extends PersistedUpdates {
|
|||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
checking: boolean;
|
checking: boolean;
|
||||||
installing: boolean;
|
installing: boolean;
|
||||||
|
installProgress: InstallProgress | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
@@ -48,6 +49,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
hydrated: false,
|
hydrated: false,
|
||||||
checking: false,
|
checking: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
|
installProgress: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
hydrate: async () => {
|
hydrate: async () => {
|
||||||
@@ -100,12 +102,18 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
installLatest: async () => {
|
installLatest: async () => {
|
||||||
const s = get();
|
const s = get();
|
||||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||||
set({ installing: true, error: null });
|
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||||
try {
|
try {
|
||||||
await downloadAndInstallApk(s.cachedLatest.apkAsset);
|
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
|
||||||
set({ installing: false });
|
set({ installProgress: p });
|
||||||
|
});
|
||||||
|
set({ installing: false, installProgress: null });
|
||||||
} catch (err) {
|
} 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