mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7654acb2 | ||
|
|
346a71de84 | ||
|
|
9a278f1be3 | ||
|
|
cf3f919fa2 | ||
|
|
792b028b7a | ||
|
|
ac84f13b22 | ||
|
|
571b28a9f2 | ||
|
|
a95783723a | ||
|
|
b36e065ebe | ||
|
|
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
@@ -19,24 +20,28 @@ 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';
|
||||||
import { useContactsStore } from './src/stores/contacts-store';
|
import { useContactsStore } from './src/stores/contacts-store';
|
||||||
import { useEmailStore } from './src/stores/email-store';
|
import { useEmailStore } from './src/stores/email-store';
|
||||||
import { useSettingsStore } from './src/stores/settings-store';
|
import { useSettingsStore } from './src/stores/settings-store';
|
||||||
|
import { useLocaleStore } from './src/stores/locale-store';
|
||||||
|
import { useNetworkStore } from './src/stores/network-store';
|
||||||
import { useUpdatesStore } from './src/stores/updates-store';
|
import { useUpdatesStore } from './src/stores/updates-store';
|
||||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||||
import { colors, spacing, typography } from './src/theme/tokens';
|
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||||
|
import { useColors } from './src/theme/colors';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||||
@@ -52,32 +57,32 @@ function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LoadingScreen({ message }: { message: string }) {
|
function LoadingScreen({ message }: { message: string }) {
|
||||||
|
const c = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={[styles.loadingContainer, { backgroundColor: c.background }]}>
|
||||||
<ActivityIndicator color={colors.primary} />
|
<ActivityIndicator color={c.primary} />
|
||||||
<Text style={styles.loadingText}>{message}</Text>
|
<Text style={[styles.loadingText, { color: c.textSecondary }]}>{message}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
|
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
|
||||||
|
const c = useColors();
|
||||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Matches webmail horizontal NavigationRail: bg-background, border-t border-border,
|
tabBarActiveTintColor: c.text,
|
||||||
// active uses foreground (white), inactive uses muted-foreground.
|
tabBarInactiveTintColor: c.textSecondary,
|
||||||
tabBarActiveTintColor: colors.text,
|
|
||||||
tabBarInactiveTintColor: colors.textSecondary,
|
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shadowOpacity: 0,
|
shadowOpacity: 0,
|
||||||
@@ -95,8 +100,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||||
tabBarBadgeStyle: {
|
tabBarBadgeStyle: {
|
||||||
backgroundColor: colors.error,
|
backgroundColor: c.error,
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
minWidth: 16,
|
minWidth: 16,
|
||||||
@@ -160,6 +165,15 @@ export default function App() {
|
|||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const client = useAuthStore((state) => state.client);
|
const client = useAuthStore((state) => state.client);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
|
|
||||||
|
// Resolve the user's theme preference to a concrete light/dark style for the
|
||||||
|
// system status bar. The rest of the app's colors are still hard-coded dark
|
||||||
|
// until the StyleSheet migration to a theme-aware `useColors` hook lands.
|
||||||
|
const themePref = useSettingsStore((state) => state.theme);
|
||||||
|
const systemScheme = useColorScheme();
|
||||||
|
const resolvedScheme: 'light' | 'dark' =
|
||||||
|
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
|
||||||
|
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
|
||||||
// Persisted active account is the signal that the user was already signed
|
// Persisted active account is the signal that the user was already signed
|
||||||
// in on the previous launch. When present we render the main UI with the
|
// in on the previous launch. When present we render the main UI with the
|
||||||
// cached mail list instead of the "Restoring session" spinner; the real
|
// cached mail list instead of the "Restoring session" spinner; the real
|
||||||
@@ -174,6 +188,8 @@ export default function App() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void useSettingsStore.getState().hydrate();
|
void useSettingsStore.getState().hydrate();
|
||||||
|
void useLocaleStore.getState().hydrate();
|
||||||
|
return useNetworkStore.getState().init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -304,7 +320,7 @@ export default function App() {
|
|||||||
if (!hasRestoredSession && !hasPersistedAccount) {
|
if (!hasRestoredSession && !hasPersistedAccount) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="light" />
|
<StatusBar style={statusBarStyle} />
|
||||||
<LoadingScreen message="Loading..." />
|
<LoadingScreen message="Loading..." />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -313,7 +329,7 @@ export default function App() {
|
|||||||
if (hasRestoredSession && !isAuthenticated) {
|
if (hasRestoredSession && !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="light" />
|
<StatusBar style={statusBarStyle} />
|
||||||
<LoginScreen />
|
<LoginScreen />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -321,10 +337,11 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer ref={navigationRef}>
|
<NavigationContainer ref={navigationRef}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style={statusBarStyle} />
|
||||||
<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}
|
||||||
@@ -368,12 +385,10 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.background,
|
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textSecondary,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
# React Native Calendar - Parity Plan
|
|
||||||
|
|
||||||
Bring `repos/react-native/` calendar to parity with the webmail calendar at
|
|
||||||
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
|
|
||||||
utility libs; mobile port targets ~2,500 lines with feature parity on the
|
|
||||||
essentials and explicit deferrals for things that don't fit a phone UX.
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day,
|
|
||||||
all rendering `MOCK_EVENTS`. Doesn't touch the store.
|
|
||||||
- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`,
|
|
||||||
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
|
|
||||||
`handleStateChange`, no per-calendar visibility, no range tracking.
|
|
||||||
- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`,
|
|
||||||
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
|
|
||||||
- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail.
|
|
||||||
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
|
|
||||||
|
|
||||||
## In scope
|
|
||||||
|
|
||||||
### Phase 1 - Data layer (foundation for everything else)
|
|
||||||
|
|
||||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
|
||||||
|
|
||||||
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
|
|
||||||
`CalendarSidebar` toggle state).
|
|
||||||
- `loadedRange: { after: string; before: string } | null` - track what's fetched
|
|
||||||
so view changes don't re-fetch unnecessarily.
|
|
||||||
- `ensureRange(after, before)` - fetch only if the requested window isn't
|
|
||||||
covered by `loadedRange`; expand the loaded range in place instead of
|
|
||||||
replacing events wholesale, so switching Month → Week doesn't drop data.
|
|
||||||
- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent`
|
|
||||||
typeState keys; on push, refetch calendars and re-query the current window.
|
|
||||||
Already referenced from `App.tsx` push handler - this finally implements it.
|
|
||||||
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
|
|
||||||
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
|
|
||||||
|
|
||||||
**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
|
|
||||||
|
|
||||||
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
|
|
||||||
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
|
|
||||||
- `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
|
|
||||||
- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
|
|
||||||
handling `showWithoutTime`.
|
|
||||||
- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
|
|
||||||
- `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
|
|
||||||
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
|
|
||||||
|
|
||||||
**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE
|
|
||||||
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
|
|
||||||
Mobile port: same surface, same tests. This is the hard part - allocate ~400
|
|
||||||
lines. Drives correct rendering of recurring events across the visible window.
|
|
||||||
|
|
||||||
### Phase 2 - Views
|
|
||||||
|
|
||||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
|
||||||
|
|
||||||
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
|
|
||||||
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
|
|
||||||
- Header shows month/week label; prev/next shifts by the current view's step.
|
|
||||||
- "Today" button resets both `currentDate` and `selectedDate`.
|
|
||||||
- FAB opens create modal with `start = selectedDate at next half hour`.
|
|
||||||
|
|
||||||
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
|
|
||||||
~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when
|
|
||||||
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
|
|
||||||
modal with that date.
|
|
||||||
|
|
||||||
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of
|
|
||||||
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
|
|
||||||
|
|
||||||
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
|
|
||||||
- All-day strip at top.
|
|
||||||
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
|
|
||||||
overlapping events (port webmail's stacking algorithm).
|
|
||||||
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
|
|
||||||
- Horizontal swipe → prev/next week (react-native-gesture-handler already
|
|
||||||
available via react-navigation).
|
|
||||||
- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small
|
|
||||||
for it to be better than tap-edit.
|
|
||||||
|
|
||||||
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of
|
|
||||||
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
|
|
||||||
days forward. Taps open detail modal.
|
|
||||||
|
|
||||||
### Phase 3 - Event interaction
|
|
||||||
|
|
||||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom
|
|
||||||
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
|
|
||||||
on web - phone version drops the hover card chrome and the inline-edit fields):
|
|
||||||
|
|
||||||
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
|
|
||||||
- Sections: location, description (linkified), participants with RSVP status,
|
|
||||||
recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders.
|
|
||||||
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
|
|
||||||
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
|
|
||||||
only" / "This and following" / "All events").
|
|
||||||
|
|
||||||
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen
|
|
||||||
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
|
|
||||||
|
|
||||||
- Title, calendar picker (color swatches), all-day toggle.
|
|
||||||
- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep).
|
|
||||||
- Location, description (multiline).
|
|
||||||
- Participant input with autocomplete from contacts store (port of
|
|
||||||
`participant-input.tsx`, ~180 lines).
|
|
||||||
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
|
|
||||||
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
|
|
||||||
are deferred - covers the 95% case.
|
|
||||||
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
|
|
||||||
`Alert.trigger.offset`.
|
|
||||||
- Timezone: default to device TZ; optional override picker hidden behind
|
|
||||||
"Advanced" disclosure.
|
|
||||||
- Save path handles "this instance only" override vs master update for
|
|
||||||
recurring events (write to `recurrenceOverrides[recurrenceId]`).
|
|
||||||
|
|
||||||
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) -
|
|
||||||
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
|
|
||||||
edit/delete of a recurring instance.
|
|
||||||
|
|
||||||
### Phase 4 - Multi-calendar
|
|
||||||
|
|
||||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) -
|
|
||||||
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
|
|
||||||
(reuses `SidebarDrawer.tsx`):
|
|
||||||
|
|
||||||
- List of calendars with color swatch + visibility toggle + name.
|
|
||||||
- "My calendars" vs "Subscribed" sections if `myRights` differs.
|
|
||||||
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
|
|
||||||
- Footer: "Add calendar" (deferred to Phase 6).
|
|
||||||
|
|
||||||
**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's
|
|
||||||
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
|
|
||||||
add any missing shades.
|
|
||||||
|
|
||||||
### Phase 5 - Polish
|
|
||||||
|
|
||||||
- Pull-to-refresh on each view → forces re-fetch of current window.
|
|
||||||
- Empty states per view (no calendars yet / no events this month).
|
|
||||||
- Loading skeleton for month grid and week grid.
|
|
||||||
- Error banner when fetch fails (pattern from `EmailListScreen`).
|
|
||||||
- Respect `email-store` push via `handleStateChange` so events update live
|
|
||||||
when another client creates one.
|
|
||||||
|
|
||||||
## Explicitly deferred (out of scope)
|
|
||||||
|
|
||||||
These are in the webmail but don't make sense for a first mobile pass:
|
|
||||||
|
|
||||||
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
|
|
||||||
`ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription
|
|
||||||
with auth flows. Better as a dedicated settings flow later.
|
|
||||||
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) -
|
|
||||||
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
|
|
||||||
screen, not bolted onto calendar.
|
|
||||||
- **Drag to reschedule / resize** - mobile tap targets are too small for the
|
|
||||||
fine-grained control the web drag gives; tap-edit is the mobile idiom.
|
|
||||||
- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow
|
|
||||||
at noon"). Neat on desktop, marginal on phone where typing is already slow.
|
|
||||||
- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop-
|
|
||||||
only. Actions move into the detail sheet's action row.
|
|
||||||
- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date
|
|
||||||
picker. Mobile already has the main month view; redundant.
|
|
||||||
- **Birthday calendar auto-derived events** - lower priority; leave the
|
|
||||||
contacts-store integration for a follow-up.
|
|
||||||
|
|
||||||
## Dependencies to add
|
|
||||||
|
|
||||||
- `@react-native-community/datetimepicker` - native date/time pickers.
|
|
||||||
- `react-native-reanimated` is already in (via react-navigation); reuse for
|
|
||||||
bottom-sheet animations on `EventDetailSheet`.
|
|
||||||
- No new calendar library - reimplement RRULE expansion in ~400 lines rather
|
|
||||||
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
|
|
||||||
|
|
||||||
## File map (new / modified)
|
|
||||||
|
|
||||||
```
|
|
||||||
repos/react-native/src/
|
|
||||||
├── screens/
|
|
||||||
│ └── CalendarScreenNew.tsx [rewrite]
|
|
||||||
├── components/
|
|
||||||
│ └── calendar/ [new directory]
|
|
||||||
│ ├── MonthView.tsx [new]
|
|
||||||
│ ├── WeekView.tsx [new]
|
|
||||||
│ ├── AgendaView.tsx [new]
|
|
||||||
│ ├── EventCard.tsx [new]
|
|
||||||
│ ├── EventDetailSheet.tsx [new]
|
|
||||||
│ ├── EventModal.tsx [new]
|
|
||||||
│ ├── ParticipantInput.tsx [new]
|
|
||||||
│ ├── RecurrenceScopeDialog.tsx [new]
|
|
||||||
│ ├── CalendarSidebarDrawer.tsx [new]
|
|
||||||
│ └── index.ts [new]
|
|
||||||
├── stores/
|
|
||||||
│ └── calendar-store.ts [extend]
|
|
||||||
├── lib/
|
|
||||||
│ ├── calendar-utils.ts [new]
|
|
||||||
│ └── recurrence-expansion.ts [new]
|
|
||||||
└── theme/
|
|
||||||
└── tokens.ts [audit calendar palette]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing approach
|
|
||||||
|
|
||||||
- `vitest.config.ts` already exists. Unit tests for:
|
|
||||||
- `recurrence-expansion` (daily/weekly/monthly, count+until, byDay).
|
|
||||||
- `calendar-utils` (duration parse, all-day overlap, DST boundary).
|
|
||||||
- Store: `ensureRange` dedup, `handleStateChange` refetch trigger.
|
|
||||||
- Manual emulator test matrix:
|
|
||||||
- Create event (timed, all-day, recurring), verify server round-trip.
|
|
||||||
- Edit recurring event via scope dialog.
|
|
||||||
- Delete recurring instance vs series.
|
|
||||||
- Toggle calendar visibility, confirm events disappear.
|
|
||||||
- Push update from webmail, confirm mobile refreshes.
|
|
||||||
|
|
||||||
## Build order (suggested)
|
|
||||||
|
|
||||||
1. Phase 1 in full - nothing else works without real data.
|
|
||||||
2. MonthView + EventDetailSheet (read path) - first thing user sees.
|
|
||||||
3. EventModal create path - unblocks manual testing of write path.
|
|
||||||
4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
|
|
||||||
5. WeekView - biggest UI, easiest to defer if time runs short.
|
|
||||||
6. EventModal edit + recurrence scope - finishes the edit surface.
|
|
||||||
7. Phase 5 polish.
|
|
||||||
|
|
||||||
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
|
|
||||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
|
||||||
we stop at Phase 2.
|
|
||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ module.exports = {
|
|||||||
web: {
|
web: {
|
||||||
favicon: './assets/favicon.png',
|
favicon: './assets/favicon.png',
|
||||||
},
|
},
|
||||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker'],
|
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
|
||||||
extra: {
|
extra: {
|
||||||
commit: COMMIT,
|
commit: COMMIT,
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+43
-2
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "1.0.0",
|
"version": "0.1.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "1.0.0",
|
"version": "0.1.11",
|
||||||
|
"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",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||||
"@react-navigation/native": "^7.2.2",
|
"@react-navigation/native": "^7.2.2",
|
||||||
"@react-navigation/native-stack": "^7.14.10",
|
"@react-navigation/native-stack": "^7.14.10",
|
||||||
@@ -17,6 +19,8 @@
|
|||||||
"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-localization": "~17.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",
|
||||||
@@ -2790,6 +2794,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-community/netinfo": {
|
||||||
|
"version": "11.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
|
||||||
|
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.59"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@@ -5234,6 +5247,28 @@
|
|||||||
"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-localization": {
|
||||||
|
"version": "17.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||||
|
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rtl-detect": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -8778,6 +8813,12 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rtl-detect": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.2",
|
"version": "0.1.11",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,11 +9,14 @@
|
|||||||
"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",
|
||||||
|
"sync-locales": "node scripts/sync-locales.mjs"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.15.9",
|
"@react-navigation/bottom-tabs": "^7.15.9",
|
||||||
"@react-navigation/native": "^7.2.2",
|
"@react-navigation/native": "^7.2.2",
|
||||||
"@react-navigation/native-stack": "^7.14.10",
|
"@react-navigation/native-stack": "^7.14.10",
|
||||||
@@ -21,6 +24,8 @@
|
|||||||
"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-localization": "~17.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}`);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copy locale JSON files from the parent webmail repo into ./locales/.
|
||||||
|
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
|
||||||
|
// In environments where the parent repo isn't checked out (e.g. CI clone of
|
||||||
|
// the standalone RN repo), this is a no-op — vendored files stay as-is.
|
||||||
|
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const RN_LOCALES = join(__dirname, '..', 'locales');
|
||||||
|
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
|
||||||
|
|
||||||
|
if (!existsSync(WEBMAIL_LOCALES)) {
|
||||||
|
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDir(src, dest) {
|
||||||
|
await mkdir(dest, { recursive: true });
|
||||||
|
for (const entry of await readdir(src, { withFileTypes: true })) {
|
||||||
|
const s = join(src, entry.name);
|
||||||
|
const d = join(dest, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDir(s, d);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||||
|
await copyFile(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
|
||||||
|
.filter((e) => e.isDirectory())
|
||||||
|
.map((e) => e.name);
|
||||||
|
|
||||||
|
let copied = 0;
|
||||||
|
for (const lang of langs) {
|
||||||
|
const src = join(WEBMAIL_LOCALES, lang);
|
||||||
|
const dest = join(RN_LOCALES, lang);
|
||||||
|
await copyDir(src, dest);
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
|
||||||
+28
-11
@@ -164,6 +164,15 @@ export async function queryEmailsByFilter(
|
|||||||
return res.methodResponses[0][1].ids;
|
return res.methodResponses[0][1].ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutgoingAttachment {
|
||||||
|
blobId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
|
cid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
email: {
|
email: {
|
||||||
from: EmailAddress[];
|
from: EmailAddress[];
|
||||||
@@ -173,7 +182,7 @@ export async function sendEmail(
|
|||||||
subject: string;
|
subject: string;
|
||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
textBody?: string;
|
textBody?: string;
|
||||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
attachments?: OutgoingAttachment[];
|
||||||
inReplyTo?: string;
|
inReplyTo?: string;
|
||||||
references?: string;
|
references?: string;
|
||||||
},
|
},
|
||||||
@@ -191,21 +200,29 @@ export async function sendEmail(
|
|||||||
keywords: { $seen: true },
|
keywords: { $seen: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bodyValues: Record<string, { value: string }> = {};
|
||||||
if (email.htmlBody) {
|
if (email.htmlBody) {
|
||||||
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
|
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
|
||||||
emailCreate.bodyValues = { html: { value: email.htmlBody } };
|
bodyValues.html = { value: email.htmlBody };
|
||||||
} else {
|
|
||||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
|
||||||
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
|
|
||||||
}
|
}
|
||||||
|
if (email.textBody || !email.htmlBody) {
|
||||||
|
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
||||||
|
bodyValues.text = { value: email.textBody ?? '' };
|
||||||
|
}
|
||||||
|
emailCreate.bodyValues = bodyValues;
|
||||||
|
|
||||||
if (email.attachments?.length) {
|
if (email.attachments?.length) {
|
||||||
emailCreate.attachments = email.attachments.map((a) => ({
|
emailCreate.attachments = email.attachments.map((a) => {
|
||||||
blobId: a.blobId,
|
const part: Record<string, unknown> = {
|
||||||
type: a.type,
|
blobId: a.blobId,
|
||||||
name: a.name,
|
type: a.type,
|
||||||
disposition: 'attachment',
|
name: a.name,
|
||||||
}));
|
disposition: a.disposition ?? 'attachment',
|
||||||
|
};
|
||||||
|
if (a.size != null) part.size = a.size;
|
||||||
|
if (a.cid) part.cid = a.cid;
|
||||||
|
return part;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.inReplyTo) {
|
if (email.inReplyTo) {
|
||||||
|
|||||||
+28
-45
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
children: string;
|
children: string;
|
||||||
@@ -8,57 +9,39 @@ interface BadgeProps {
|
|||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches webmail badge patterns:
|
|
||||||
* - rounded-full px-2 py-0.5 text-xs font-medium
|
|
||||||
*/
|
|
||||||
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
|
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.base, variantStyles[variant], style]}>
|
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
|
||||||
<Text style={[styles.text, textVariantStyles[variant]]}>
|
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
base: {
|
return StyleSheet.create({
|
||||||
borderRadius: radius.full,
|
base: {
|
||||||
paddingHorizontal: 8, // px-2
|
borderRadius: radius.full,
|
||||||
paddingVertical: 2, // py-0.5
|
paddingHorizontal: 8,
|
||||||
alignSelf: 'flex-start',
|
paddingVertical: 2,
|
||||||
},
|
alignSelf: 'flex-start',
|
||||||
text: {
|
},
|
||||||
...typography.caption,
|
text: { ...typography.caption, fontWeight: '500' },
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const variantStyles: Record<string, ViewStyle> = {
|
bg_default: { backgroundColor: c.surface },
|
||||||
default: {
|
bg_primary: { backgroundColor: c.primaryBg },
|
||||||
backgroundColor: colors.surface,
|
bg_destructive: { backgroundColor: c.errorBg },
|
||||||
},
|
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
|
||||||
primary: {
|
bg_muted: { backgroundColor: c.muted },
|
||||||
backgroundColor: colors.primaryBg,
|
|
||||||
},
|
text_default: { color: c.text },
|
||||||
destructive: {
|
text_primary: { color: c.primary },
|
||||||
backgroundColor: colors.errorBg,
|
text_destructive: { color: c.error },
|
||||||
},
|
text_outline: { color: c.text },
|
||||||
outline: {
|
text_muted: { color: c.mutedForeground },
|
||||||
backgroundColor: 'transparent',
|
});
|
||||||
borderWidth: 1,
|
}
|
||||||
borderColor: colors.border,
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
backgroundColor: colors.muted,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const textVariantStyles: Record<string, { color: string }> = {
|
|
||||||
default: { color: colors.text },
|
|
||||||
primary: { color: colors.primary },
|
|
||||||
destructive: { color: colors.error },
|
|
||||||
outline: { color: colors.text },
|
|
||||||
muted: { color: colors.mutedForeground },
|
|
||||||
};
|
|
||||||
|
|||||||
+38
-106
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator } from 'react-native';
|
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
|
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
|
||||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||||
@@ -8,7 +9,7 @@ type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
|||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
children: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -26,15 +27,17 @@ export default function Button({
|
|||||||
icon,
|
icon,
|
||||||
style,
|
style,
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const isDisabled = disabled || loading;
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.base,
|
styles.base,
|
||||||
sizeStyles[size],
|
styles[`size_${size}` as const],
|
||||||
variantStyles[variant],
|
styles[`bg_${variant}` as const],
|
||||||
pressed && !isDisabled && pressedStyles[variant],
|
pressed && !isDisabled && styles[`pressed_${variant}` as const],
|
||||||
isDisabled && styles.disabled,
|
isDisabled && styles.disabled,
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
@@ -44,13 +47,13 @@ export default function Button({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
size="small"
|
size="small"
|
||||||
color={variant === 'default' ? colors.primaryForeground : colors.primary}
|
color={variant === 'default' ? c.primaryForeground : c.primary}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{icon}
|
{icon}
|
||||||
{typeof children === 'string' ? (
|
{typeof children === 'string' ? (
|
||||||
<Text style={[styles.text, textVariantStyles[variant], isDisabled && styles.textDisabled]}>
|
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -62,105 +65,34 @@ export default function Button({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
base: {
|
return StyleSheet.create({
|
||||||
flexDirection: 'row',
|
base: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
justifyContent: 'center',
|
borderRadius: radius.md, gap: spacing.sm,
|
||||||
borderRadius: radius.md, // rounded-md (web)
|
},
|
||||||
gap: spacing.sm,
|
text: { ...typography.bodyMedium },
|
||||||
},
|
disabled: { opacity: 0.5 },
|
||||||
text: {
|
textDisabled: { opacity: 0.5 },
|
||||||
...typography.bodyMedium, // text-sm font-medium (web)
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
textDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Matches web: h-9 (sm), h-10 (md), h-11 (lg), h-10 w-10 (icon)
|
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
|
||||||
const sizeStyles: Record<ButtonSize, ViewStyle> = {
|
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
|
||||||
sm: {
|
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
|
||||||
height: componentSizes.buttonSm,
|
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
|
||||||
paddingHorizontal: spacing.md, // px-3
|
|
||||||
},
|
|
||||||
md: {
|
|
||||||
height: componentSizes.buttonMd,
|
|
||||||
paddingHorizontal: spacing.lg, // px-4
|
|
||||||
paddingVertical: spacing.sm, // py-2
|
|
||||||
},
|
|
||||||
lg: {
|
|
||||||
height: componentSizes.buttonLg,
|
|
||||||
paddingHorizontal: spacing.xxxl, // px-8
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
height: componentSizes.buttonMd,
|
|
||||||
width: componentSizes.buttonMd,
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Matches web button variant classes exactly
|
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||||
const variantStyles: Record<ButtonVariant, ViewStyle> = {
|
bg_ghost: { backgroundColor: 'transparent' },
|
||||||
default: {
|
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
|
||||||
backgroundColor: colors.primary,
|
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 1,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
|
||||||
ghost: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
outline: {
|
|
||||||
backgroundColor: colors.background,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
backgroundColor: colors.error,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 1,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const pressedStyles: Record<ButtonVariant, ViewStyle> = {
|
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
|
||||||
default: {
|
pressed_ghost: { backgroundColor: c.accent },
|
||||||
backgroundColor: colors.primaryDark,
|
pressed_outline: { backgroundColor: c.accent },
|
||||||
shadowOffset: { width: 0, height: 2 },
|
pressed_destructive: { opacity: 0.9 },
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 2,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
ghost: {
|
|
||||||
backgroundColor: colors.accent,
|
|
||||||
},
|
|
||||||
outline: {
|
|
||||||
backgroundColor: colors.accent,
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const textVariantStyles: Record<ButtonVariant, TextStyle> = {
|
text_default: { color: c.primaryForeground },
|
||||||
default: {
|
text_ghost: { color: c.text },
|
||||||
color: colors.primaryForeground,
|
text_outline: { color: c.text },
|
||||||
},
|
text_destructive: { color: c.errorForeground },
|
||||||
ghost: {
|
});
|
||||||
color: colors.text,
|
}
|
||||||
},
|
|
||||||
outline: {
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
color: colors.errorForeground,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
+19
-10
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -13,6 +14,8 @@ interface CardProps {
|
|||||||
* - card-foreground for text
|
* - card-foreground for text
|
||||||
*/
|
*/
|
||||||
export function Card({ children, style }: CardProps) {
|
export function Card({ children, style }: CardProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return <View style={[styles.card, style]}>{children}</View>;
|
return <View style={[styles.card, style]}>{children}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +30,8 @@ interface SectionHeaderProps {
|
|||||||
* - text-sm text-muted-foreground for description
|
* - text-sm text-muted-foreground for description
|
||||||
*/
|
*/
|
||||||
export function SectionHeader({ title, description }: SectionHeaderProps) {
|
export function SectionHeader({ title, description }: SectionHeaderProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
@@ -51,6 +56,8 @@ interface SettingItemRowProps {
|
|||||||
* - description: text-xs text-muted-foreground mt-1
|
* - description: text-xs text-muted-foreground mt-1
|
||||||
*/
|
*/
|
||||||
export function SettingItemRow({ label, description, right, icon, iconBg, bottomBorder = true }: SettingItemRowProps) {
|
export function SettingItemRow({ label, description, right, icon, iconBg, bottomBorder = true }: SettingItemRowProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.settingRow, bottomBorder && styles.settingRowBorder]}>
|
<View style={[styles.settingRow, bottomBorder && styles.settingRowBorder]}>
|
||||||
{icon && (
|
{icon && (
|
||||||
@@ -67,12 +74,13 @@ export function SettingItemRow({ label, description, right, icon, iconBg, bottom
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
borderRadius: radius.lg, // rounded-lg
|
borderRadius: radius.lg, // rounded-lg
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
@@ -81,11 +89,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
...typography.h3, // text-lg font-medium
|
...typography.h3, // text-lg font-medium
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
sectionDescription: {
|
sectionDescription: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.mutedForeground, // text-muted-foreground
|
color: c.mutedForeground, // text-muted-foreground
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
settingRow: {
|
settingRow: {
|
||||||
@@ -97,7 +105,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
settingRowBorder: {
|
settingRowBorder: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border, // border-b border-border
|
borderBottomColor: c.border, // border-b border-border
|
||||||
},
|
},
|
||||||
settingIcon: {
|
settingIcon: {
|
||||||
width: 32,
|
width: 32,
|
||||||
@@ -112,14 +120,15 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
settingLabel: {
|
settingLabel: {
|
||||||
...typography.bodyMedium, // text-sm font-medium text-foreground
|
...typography.bodyMedium, // text-sm font-medium text-foreground
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
settingDescription: {
|
settingDescription: {
|
||||||
...typography.caption, // text-xs text-muted-foreground
|
...typography.caption, // text-xs text-muted-foreground
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
settingRight: {
|
settingRight: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
|
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
|
||||||
import { AlertTriangle } from 'lucide-react-native';
|
import { AlertTriangle } from 'lucide-react-native';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -34,6 +35,8 @@ export default function Dialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
@@ -41,7 +44,7 @@ export default function Dialog({
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{variant === 'destructive' && (
|
{variant === 'destructive' && (
|
||||||
<View style={styles.iconBadge}>
|
<View style={styles.iconBadge}>
|
||||||
<AlertTriangle size={20} color={colors.error} />
|
<AlertTriangle size={20} color={c.error} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
@@ -61,7 +64,8 @@ export default function Dialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
@@ -72,9 +76,9 @@ const styles = StyleSheet.create({
|
|||||||
dialog: {
|
dialog: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 10 },
|
shadowOffset: { width: 0, height: 10 },
|
||||||
@@ -89,18 +93,18 @@ const styles = StyleSheet.create({
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.errorBg,
|
backgroundColor: c.errorBg,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
...typography.h3, // text-lg font-semibold
|
...typography.h3, // text-lg font-semibold
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
...typography.body, // text-sm
|
...typography.body, // text-sm
|
||||||
color: colors.mutedForeground, // text-muted-foreground
|
color: c.mutedForeground, // text-muted-foreground
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
@@ -110,4 +114,5 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.xxl, // px-6
|
paddingHorizontal: spacing.xxl, // px-6
|
||||||
paddingBottom: spacing.xxl, // pb-6
|
paddingBottom: spacing.xxl, // pb-6
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import type { Email } from '../api/types';
|
|||||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
|
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { colors, spacing, typography } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
|
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
|
||||||
// skipped so we don't freeze the JS thread base64-encoding megabytes.
|
// skipped so we don't freeze the JS thread base64-encoding megabytes.
|
||||||
@@ -129,6 +130,8 @@ const HEIGHT_REPORTER = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
|
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||||
@@ -247,13 +250,13 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
|||||||
<View style={styles.banner}>
|
<View style={styles.banner}>
|
||||||
{externalContentPolicy === 'ask' && (
|
{externalContentPolicy === 'ask' && (
|
||||||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||||||
<ImageIcon size={14} color={colors.textSecondary} />
|
<ImageIcon size={14} color={c.textSecondary} />
|
||||||
<Text style={styles.bannerButtonText}>Load external content</Text>
|
<Text style={styles.bannerButtonText}>Load external content</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{senderEmail ? (
|
{senderEmail ? (
|
||||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||||
<ShieldCheck size={14} color={colors.textSecondary} />
|
<ShieldCheck size={14} color={c.textSecondary} />
|
||||||
<Text style={styles.bannerButtonText}>Trust sender</Text>
|
<Text style={styles.bannerButtonText}>Trust sender</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -308,7 +311,8 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
wrapper: { width: '100%' },
|
wrapper: { width: '100%' },
|
||||||
banner: {
|
banner: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -317,9 +321,9 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
backgroundColor: colors.surfaceHover,
|
backgroundColor: c.surfaceHover,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
bannerButton: {
|
bannerButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -329,7 +333,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
bannerButtonText: {
|
bannerButtonText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
webContainer: {
|
webContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -343,4 +347,5 @@ const styles = StyleSheet.create({
|
|||||||
webviewContainer: {
|
webviewContainer: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+16
-11
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native';
|
import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
interface InputProps extends TextInputProps {
|
interface InputProps extends TextInputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -28,6 +29,8 @@ export default function Input({
|
|||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [focused, setFocused] = React.useState(false);
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +46,7 @@ export default function Input({
|
|||||||
<RNTextInput
|
<RNTextInput
|
||||||
{...props}
|
{...props}
|
||||||
style={[styles.input, style]}
|
style={[styles.input, style]}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
|
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
|
||||||
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
|
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
|
||||||
/>
|
/>
|
||||||
@@ -54,10 +57,11 @@ export default function Input({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
label: {
|
label: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
inputWrapper: {
|
inputWrapper: {
|
||||||
@@ -65,17 +69,17 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: componentSizes.inputHeight, // h-10 = 40px
|
height: componentSizes.inputHeight, // h-10 = 40px
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border, // border-input
|
borderColor: c.border, // border-input
|
||||||
borderRadius: radius.sm, // rounded-md = 6px
|
borderRadius: radius.sm, // rounded-md = 6px
|
||||||
backgroundColor: colors.background, // bg-background
|
backgroundColor: c.background, // bg-background
|
||||||
paddingHorizontal: spacing.md, // px-3 = 12px
|
paddingHorizontal: spacing.md, // px-3 = 12px
|
||||||
},
|
},
|
||||||
inputFocused: {
|
inputFocused: {
|
||||||
borderColor: colors.borderFocus, // border-ring (blue-500)
|
borderColor: c.borderFocus, // border-ring (blue-500)
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
inputError: {
|
inputError: {
|
||||||
borderColor: colors.error,
|
borderColor: c.error,
|
||||||
},
|
},
|
||||||
inputDisabled: {
|
inputDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
@@ -83,7 +87,7 @@ const styles = StyleSheet.create({
|
|||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
...typography.body, // text-sm = 14px
|
...typography.body, // text-sm = 14px
|
||||||
color: colors.text, // text-foreground
|
color: c.text, // text-foreground
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
},
|
},
|
||||||
iconLeft: {
|
iconLeft: {
|
||||||
@@ -94,7 +98,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.error,
|
color: c.error,
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { CloudOff } from 'lucide-react-native';
|
||||||
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
|
interface OfflineBannerProps {
|
||||||
|
/** Optional extra notice (e.g. "Showing cached mail"). */
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineBanner({ hint }: OfflineBannerProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const online = useNetworkStore((s) => s.online);
|
||||||
|
if (online) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.bar}>
|
||||||
|
<CloudOff size={14} color={c.primaryForeground} />
|
||||||
|
<Text style={styles.text} numberOfLines={1}>
|
||||||
|
You are offline{hint ? ` — ${hint}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
bar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#a16207',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
...typography.caption,
|
||||||
|
color: c.primaryForeground,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, Pressable, StyleSheet, ViewStyle } from 'react-native';
|
import { View, Text, Pressable, StyleSheet, ViewStyle } from 'react-native';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
interface RadioOption {
|
interface RadioOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -22,6 +23,8 @@ interface RadioGroupProps {
|
|||||||
* - unselected: bg-muted text-foreground
|
* - unselected: bg-muted text-foreground
|
||||||
*/
|
*/
|
||||||
export default function RadioGroup({ options, value, onChange, style }: RadioGroupProps) {
|
export default function RadioGroup({ options, value, onChange, style }: RadioGroupProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<View style={[styles.container, style]}>
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
@@ -42,7 +45,8 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 6, // gap-1.5
|
gap: 6, // gap-1.5
|
||||||
@@ -53,19 +57,20 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.sm, // rounded-md
|
borderRadius: radius.sm, // rounded-md
|
||||||
},
|
},
|
||||||
optionSelected: {
|
optionSelected: {
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
optionUnselected: {
|
optionUnselected: {
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
optionText: {
|
optionText: {
|
||||||
...typography.caption, // text-xs
|
...typography.caption, // text-xs
|
||||||
},
|
},
|
||||||
textSelected: {
|
textSelected: {
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
textUnselected: {
|
textUnselected: {
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||||
} 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 } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { useAuthStore } from '../stores/auth-store';
|
import { useAuthStore } from '../stores/auth-store';
|
||||||
import { useAccountStore } from '../stores/account-store';
|
import { useAccountStore } from '../stores/account-store';
|
||||||
@@ -48,21 +49,23 @@ function iconFor(
|
|||||||
return Folder;
|
return Folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_COLOR: Record<string, string> = {
|
const ROLE_COLOR_FIXED: Record<string, string> = {
|
||||||
inbox: '#60a5fa',
|
inbox: '#60a5fa',
|
||||||
sent: '#4ade80',
|
sent: '#4ade80',
|
||||||
drafts: '#a78bfa',
|
drafts: '#a78bfa',
|
||||||
trash: colors.textMuted,
|
|
||||||
junk: '#f87171',
|
junk: '#f87171',
|
||||||
archive: '#fbbf24',
|
archive: '#fbbf24',
|
||||||
};
|
};
|
||||||
|
|
||||||
function iconColor(role: string | null | undefined, isSelected: boolean): string {
|
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
|
||||||
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
|
if (role === 'trash') return c.textMuted;
|
||||||
return isSelected ? colors.text : colors.textSecondary;
|
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
|
||||||
|
return isSelected ? c.text : c.textSecondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RowCounts({ unread, total }: { unread: number; total: number }) {
|
function RowCounts({ unread, total }: { unread: number; total: number }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
if (unread === 0 && total === 0) return null;
|
if (unread === 0 && total === 0) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.counts}>
|
<View style={styles.counts}>
|
||||||
@@ -90,6 +93,8 @@ function SidebarRow({
|
|||||||
icon, label, depth, isSelected, unread, total,
|
icon, label, depth, isSelected, unread, total,
|
||||||
hasChildren, isExpanded, onPress, onToggleExpand,
|
hasChildren, isExpanded, onPress, onToggleExpand,
|
||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -108,9 +113,9 @@ function SidebarRow({
|
|||||||
style={styles.chevron}
|
style={styles.chevron}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown size={12} color={colors.textMuted} />
|
<ChevronDown size={12} color={c.textMuted} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight size={12} color={colors.textMuted} />
|
<ChevronRight size={12} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
@@ -135,6 +140,8 @@ interface SidebarDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
|
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
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 selectMailbox = useEmailStore((s) => s.selectMailbox);
|
const selectMailbox = useEmailStore((s) => s.selectMailbox);
|
||||||
@@ -237,7 +244,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
[accountEmail],
|
[accountEmail],
|
||||||
);
|
);
|
||||||
const avatarBg = React.useMemo(
|
const avatarBg = React.useMemo(
|
||||||
() => (accountEmail ? generateAvatarColor(accountEmail) : colors.primary),
|
() => (accountEmail ? generateAvatarColor(accountEmail) : c.primary),
|
||||||
[accountEmail],
|
[accountEmail],
|
||||||
);
|
);
|
||||||
const hostname = React.useMemo(() => {
|
const hostname = React.useMemo(() => {
|
||||||
@@ -267,7 +274,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
{/* Header: close + account switcher */}
|
{/* Header: close + account switcher */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||||
<X size={20} color={colors.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setAccountMenuOpen((v) => !v)}
|
onPress={() => setAccountMenuOpen((v) => !v)}
|
||||||
@@ -284,7 +291,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
</View>
|
</View>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
color={colors.textMuted}
|
color={c.textMuted}
|
||||||
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
|
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -323,7 +330,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
</View>
|
</View>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<View style={styles.accountMenuCheckBadge}>
|
<View style={styles.accountMenuCheckBadge}>
|
||||||
<Check size={10} color={colors.primaryForeground} strokeWidth={3} />
|
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -364,7 +371,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
navigation.navigate('AddAccount');
|
navigation.navigate('AddAccount');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={16} color={colors.textSecondary} />
|
<Plus size={16} color={c.textSecondary} />
|
||||||
<Text style={styles.accountMenuActionText}>Add account</Text>
|
<Text style={styles.accountMenuActionText}>Add account</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
@@ -382,7 +389,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
]}
|
]}
|
||||||
onPress={() => setDefaultAccount(active.id)}
|
onPress={() => setDefaultAccount(active.id)}
|
||||||
>
|
>
|
||||||
<Star size={16} color={colors.textSecondary} />
|
<Star size={16} color={c.textSecondary} />
|
||||||
<Text style={styles.accountMenuActionText}>Set as default</Text>
|
<Text style={styles.accountMenuActionText}>Set as default</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -394,7 +401,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
]}
|
]}
|
||||||
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
|
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
|
||||||
>
|
>
|
||||||
<LogOut size={16} color={colors.textSecondary} />
|
<LogOut size={16} color={c.textSecondary} />
|
||||||
<Text style={styles.accountMenuActionText} numberOfLines={1}>
|
<Text style={styles.accountMenuActionText} numberOfLines={1}>
|
||||||
Sign out of {accountEmailFull || 'account'}
|
Sign out of {accountEmailFull || 'account'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -407,8 +414,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
]}
|
]}
|
||||||
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
|
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
|
||||||
>
|
>
|
||||||
<LogOut size={16} color={colors.error} />
|
<LogOut size={16} color={c.error} />
|
||||||
<Text style={[styles.accountMenuActionText, { color: colors.error }]}>
|
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
|
||||||
Sign out all
|
Sign out all
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -420,14 +427,14 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
{/* Folders section */}
|
{/* Folders section */}
|
||||||
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
|
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
|
||||||
{foldersExpanded ? (
|
{foldersExpanded ? (
|
||||||
<ChevronDown size={14} color={colors.textMuted} />
|
<ChevronDown size={14} color={c.textMuted} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight size={14} color={colors.textMuted} />
|
<ChevronRight size={14} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.sectionHeaderText}>Folders</Text>
|
<Text style={styles.sectionHeaderText}>Folders</Text>
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
<View style={styles.sectionSettings}>
|
<View style={styles.sectionSettings}>
|
||||||
<Settings size={14} color={colors.textMuted} />
|
<Settings size={14} color={c.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
@@ -443,7 +450,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
return (
|
return (
|
||||||
<SidebarRow
|
<SidebarRow
|
||||||
key={node.id}
|
key={node.id}
|
||||||
icon={<Icon size={16} color={iconColor(node.role, isSelected)} />}
|
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
|
||||||
label={node.name}
|
label={node.name}
|
||||||
depth={node.depth}
|
depth={node.depth}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
@@ -465,7 +472,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
@@ -478,7 +486,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: 340,
|
maxWidth: 340,
|
||||||
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: colors.border,
|
borderRightColor: c.border,
|
||||||
},
|
},
|
||||||
drawerSafe: { flex: 1 },
|
drawerSafe: { flex: 1 },
|
||||||
|
|
||||||
@@ -490,7 +498,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
headerClose: {
|
headerClose: {
|
||||||
width: 36, height: 36,
|
width: 36, height: 36,
|
||||||
@@ -506,7 +514,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
accountPressed: { backgroundColor: colors.surfaceHover },
|
accountPressed: { backgroundColor: c.surfaceHover },
|
||||||
accountChevronOpen: { transform: [{ rotate: '180deg' }] },
|
accountChevronOpen: { transform: [{ rotate: '180deg' }] },
|
||||||
accountAvatar: {
|
accountAvatar: {
|
||||||
width: 36, height: 36,
|
width: 36, height: 36,
|
||||||
@@ -515,11 +523,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
accountAvatarText: {
|
accountAvatarText: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
},
|
},
|
||||||
accountInfo: { flex: 1, minWidth: 0 },
|
accountInfo: { flex: 1, minWidth: 0 },
|
||||||
accountName: { ...typography.bodyMedium, color: colors.text },
|
accountName: { ...typography.bodyMedium, color: c.text },
|
||||||
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
accountEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||||
|
|
||||||
// Account menu - floating popover card under the header
|
// Account menu - floating popover card under the header
|
||||||
accountMenu: {
|
accountMenu: {
|
||||||
@@ -529,7 +537,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#1f1f1f',
|
backgroundColor: '#1f1f1f',
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
// Elevation / shadow
|
// Elevation / shadow
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@@ -566,14 +574,14 @@ const styles = StyleSheet.create({
|
|||||||
accountMenuAvatarText: {
|
accountMenuAvatarText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
},
|
},
|
||||||
accountMenuCheckBadge: {
|
accountMenuCheckBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: -3, bottom: -3,
|
right: -3, bottom: -3,
|
||||||
width: 16, height: 16,
|
width: 16, height: 16,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: '#1f1f1f',
|
borderColor: '#1f1f1f',
|
||||||
@@ -586,13 +594,13 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
accountMenuName: {
|
accountMenuName: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
accountMenuHost: {
|
accountMenuHost: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
accountMenuStatusRow: {
|
accountMenuStatusRow: {
|
||||||
@@ -607,17 +615,17 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#22c55e',
|
backgroundColor: '#22c55e',
|
||||||
},
|
},
|
||||||
accountMenuStatusDotOffline: {
|
accountMenuStatusDotOffline: {
|
||||||
backgroundColor: colors.textMuted,
|
backgroundColor: c.textMuted,
|
||||||
},
|
},
|
||||||
accountMenuStatusText: {
|
accountMenuStatusText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
lineHeight: 12,
|
lineHeight: 12,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
accountMenuDivider: {
|
accountMenuDivider: {
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
backgroundColor: colors.border,
|
backgroundColor: c.border,
|
||||||
},
|
},
|
||||||
accountMenuAction: {
|
accountMenuAction: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -627,10 +635,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
minHeight: 40,
|
minHeight: 40,
|
||||||
},
|
},
|
||||||
accountMenuActionPressed: { backgroundColor: colors.surfaceHover },
|
accountMenuActionPressed: { backgroundColor: c.surfaceHover },
|
||||||
accountMenuActionText: {
|
accountMenuActionText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
@@ -650,13 +658,13 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
sectionHeaderText: {
|
sectionHeaderText: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
sectionSettings: { padding: 4 },
|
sectionSettings: { padding: 4 },
|
||||||
|
|
||||||
empty: {
|
empty: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
@@ -670,10 +678,10 @@ const styles = StyleSheet.create({
|
|||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
borderLeftColor: 'transparent',
|
borderLeftColor: 'transparent',
|
||||||
},
|
},
|
||||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
rowSelected: {
|
rowSelected: {
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: c.accent,
|
||||||
borderLeftColor: colors.primary,
|
borderLeftColor: c.primary,
|
||||||
},
|
},
|
||||||
rowIndent: {
|
rowIndent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -692,11 +700,11 @@ const styles = StyleSheet.create({
|
|||||||
rowLabel: {
|
rowLabel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
rowLabelSelected: {
|
rowLabelSelected: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
counts: {
|
counts: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -707,16 +715,17 @@ const styles = StyleSheet.create({
|
|||||||
countUnread: {
|
countUnread: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
countSep: {
|
countSep: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
countTotal: {
|
countTotal: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import {
|
||||||
|
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
|
import { typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
import type { SwipeAction } from '../stores/settings-store';
|
||||||
|
|
||||||
|
interface SwipeableRowProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Right-to-left swipe action (revealed under a leftward drag, sits at right edge). */
|
||||||
|
leftAction: SwipeAction;
|
||||||
|
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
|
||||||
|
rightAction: SwipeAction;
|
||||||
|
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||||
|
context: { unread: boolean; starred: boolean };
|
||||||
|
onAction: (action: SwipeAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVEAL_WIDTH = 88; // px shown when fully revealed
|
||||||
|
const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing
|
||||||
|
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||||
|
const MIN_DX_TO_CLAIM = 6;
|
||||||
|
const MAX_DRAG_OVERSHOOT = REVEAL_WIDTH * 1.4;
|
||||||
|
|
||||||
|
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
|
||||||
|
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
|
||||||
|
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
|
||||||
|
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
|
||||||
|
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
|
||||||
|
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
|
||||||
|
if (action === 'read') return context.unread ? 'Read' : 'Unread';
|
||||||
|
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
|
||||||
|
if (action === 'none') return '';
|
||||||
|
return ACTION_META[action].defaultLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwipeableRow({
|
||||||
|
children, leftAction, rightAction, context, onAction,
|
||||||
|
}: SwipeableRowProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const dx = useRef(new Animated.Value(0)).current;
|
||||||
|
const claimed = useRef(false);
|
||||||
|
const widthRef = useRef(Dimensions.get('window').width);
|
||||||
|
const openSideRef = useRef<'left' | 'right' | null>(null);
|
||||||
|
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||||
|
openSideRef.current = null;
|
||||||
|
setOpenSide(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRight = () => {
|
||||||
|
Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||||
|
openSideRef.current = 'right';
|
||||||
|
setOpenSide('right');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLeft = () => {
|
||||||
|
Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||||
|
openSideRef.current = 'left';
|
||||||
|
setOpenSide('left');
|
||||||
|
};
|
||||||
|
|
||||||
|
const responder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => false,
|
||||||
|
onMoveShouldSetPanResponder: (_, g) => {
|
||||||
|
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||||
|
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||||
|
// If a side is already open, allow the gesture so the user can drag it closed.
|
||||||
|
if (openSideRef.current === null) {
|
||||||
|
if (g.dx > 0 && rightAction === 'none') return false;
|
||||||
|
if (g.dx < 0 && leftAction === 'none') return false;
|
||||||
|
}
|
||||||
|
claimed.current = true;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onPanResponderMove: (_, g) => {
|
||||||
|
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
|
||||||
|
const next = base + g.dx;
|
||||||
|
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next));
|
||||||
|
dx.setValue(clamped);
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, g) => {
|
||||||
|
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
|
||||||
|
const finalDx = base + g.dx;
|
||||||
|
|
||||||
|
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||||
|
openRight();
|
||||||
|
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||||
|
openLeft();
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
claimed.current = false;
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
close();
|
||||||
|
claimed.current = false;
|
||||||
|
},
|
||||||
|
onPanResponderTerminationRequest: () => !claimed.current,
|
||||||
|
}),
|
||||||
|
).current;
|
||||||
|
|
||||||
|
const fireAction = (action: SwipeAction) => {
|
||||||
|
close();
|
||||||
|
if (action !== 'none') onAction(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
|
||||||
|
if (action === 'none') return null;
|
||||||
|
const meta = ACTION_META[action];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const label = actionLabel(action, context);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => fireAction(action)}
|
||||||
|
style={[
|
||||||
|
styles.band,
|
||||||
|
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
|
||||||
|
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.bandInner}>
|
||||||
|
<Icon size={20} color="#fff" />
|
||||||
|
<Text style={styles.bandLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||||
|
widthRef.current = e.nativeEvent.layout.width;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||||
|
{renderBand(rightAction, 'left')}
|
||||||
|
{renderBand(leftAction, 'right')}
|
||||||
|
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
|
||||||
|
{children}
|
||||||
|
{/* When an action is revealed, an overlay absorbs taps on the row so a
|
||||||
|
tap closes the swipe instead of opening the email. */}
|
||||||
|
{openSide ? (
|
||||||
|
<Pressable
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={close}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
wrap: { position: 'relative', backgroundColor: c.background },
|
||||||
|
content: { backgroundColor: c.background },
|
||||||
|
band: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
bandInner: { alignItems: 'center', gap: 4 },
|
||||||
|
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, View, StyleSheet } from 'react-native';
|
import { Pressable, View, StyleSheet } from 'react-native';
|
||||||
import { colors, radius, componentSizes } from '../theme/tokens';
|
import { radius, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
value: boolean;
|
value: boolean;
|
||||||
@@ -16,6 +17,8 @@ interface ToggleSwitchProps {
|
|||||||
* - translate-x-6 (checked) / translate-x-1 (unchecked)
|
* - translate-x-6 (checked) / translate-x-1 (unchecked)
|
||||||
*/
|
*/
|
||||||
export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) {
|
export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={[
|
||||||
@@ -37,7 +40,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
track: {
|
track: {
|
||||||
width: componentSizes.toggleWidth, // w-11 = 44
|
width: componentSizes.toggleWidth, // w-11 = 44
|
||||||
height: componentSizes.toggleHeight, // h-6 = 24
|
height: componentSizes.toggleHeight, // h-6 = 24
|
||||||
@@ -45,18 +49,19 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
trackOn: {
|
trackOn: {
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
trackOff: {
|
trackOff: {
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
thumb: {
|
thumb: {
|
||||||
width: componentSizes.toggleThumb, // h-4 w-4 = 16
|
width: componentSizes.toggleThumb, // h-4 w-4 = 16
|
||||||
height: componentSizes.toggleThumb,
|
height: componentSizes.toggleThumb,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Download, X } from 'lucide-react-native';
|
import { Download, X } from 'lucide-react-native';
|
||||||
import { useUpdatesStore } from '../stores/updates-store';
|
import { useUpdatesStore } from '../stores/updates-store';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
export function UpdateBanner(): React.ReactElement | null {
|
export function UpdateBanner(): React.ReactElement | null {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||||
const installing = useUpdatesStore((s) => s.installing);
|
const installing = useUpdatesStore((s) => s.installing);
|
||||||
@@ -17,8 +22,8 @@ export function UpdateBanner(): React.ReactElement | null {
|
|||||||
if (!cachedLatest?.apkAsset) return null;
|
if (!cachedLatest?.apkAsset) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.banner}>
|
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
|
||||||
<Download size={16} color={colors.primaryForeground} />
|
<Download size={16} color={c.primaryForeground} />
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.title}>Update available</Text>
|
<Text style={styles.title}>Update available</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
@@ -33,29 +38,31 @@ export function UpdateBanner(): React.ReactElement | null {
|
|||||||
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
|
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
|
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
|
||||||
<X size={14} color={colors.primaryForeground} />
|
<X size={14} color={c.primaryForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
banner: {
|
banner: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
title: { ...typography.bodyMedium, color: colors.primaryForeground },
|
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||||
subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 },
|
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 },
|
||||||
installButton: {
|
installButton: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
},
|
},
|
||||||
installText: { ...typography.captionMedium, color: colors.primaryForeground },
|
installText: { ...typography.captionMedium, color: c.primaryForeground },
|
||||||
dismiss: { padding: 4 },
|
dismiss: { padding: 4 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { SectionList, StyleSheet, Text, View } from 'react-native';
|
|||||||
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
||||||
import { CalendarDays } from 'lucide-react-native';
|
import { CalendarDays } from 'lucide-react-native';
|
||||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
import { colors, spacing, typography } from '../../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import {
|
import {
|
||||||
buildEventDayIndex,
|
buildEventDayIndex,
|
||||||
eventsOnDayFromIndex,
|
eventsOnDayFromIndex,
|
||||||
@@ -41,6 +42,8 @@ export function AgendaView({
|
|||||||
eventsByDay,
|
eventsByDay,
|
||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
}: AgendaViewProps) {
|
}: AgendaViewProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const index = React.useMemo(
|
const index = React.useMemo(
|
||||||
() => eventsByDay ?? buildEventDayIndex(events),
|
() => eventsByDay ?? buildEventDayIndex(events),
|
||||||
[eventsByDay, events],
|
[eventsByDay, events],
|
||||||
@@ -78,7 +81,7 @@ export function AgendaView({
|
|||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<CalendarDays size={40} color={colors.surfaceActive} />
|
<CalendarDays size={40} color={c.surfaceActive} />
|
||||||
<Text style={styles.emptyTitle}>No upcoming events</Text>
|
<Text style={styles.emptyTitle}>No upcoming events</Text>
|
||||||
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text>
|
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -118,7 +121,8 @@ export function AgendaView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
listContent: { paddingBottom: 80 },
|
listContent: { paddingBottom: 80 },
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -126,16 +130,16 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
sectionTitle: { ...typography.bodyMedium, color: colors.text },
|
sectionTitle: { ...typography.bodyMedium, color: c.text },
|
||||||
sectionTitleToday: { color: colors.primary },
|
sectionTitleToday: { color: c.primary },
|
||||||
sectionSub: { ...typography.caption, color: colors.textMuted },
|
sectionSub: { ...typography.caption, color: c.textMuted },
|
||||||
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||||
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||||
emptyDayText: { ...typography.caption, color: colors.textMuted, textAlign: 'center' },
|
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
|
||||||
empty: {
|
empty: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -143,6 +147,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
paddingVertical: spacing.xxxl,
|
paddingVertical: spacing.xxxl,
|
||||||
},
|
},
|
||||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Check, X } from 'lucide-react-native';
|
import { Check, X } from 'lucide-react-native';
|
||||||
import type { Calendar } from '../../api/types';
|
import type { Calendar } from '../../api/types';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||||
|
|
||||||
interface CalendarSidebarDrawerProps {
|
interface CalendarSidebarDrawerProps {
|
||||||
@@ -31,6 +32,8 @@ export function CalendarSidebarDrawer({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onClose,
|
onClose,
|
||||||
}: CalendarSidebarDrawerProps) {
|
}: CalendarSidebarDrawerProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@ export function CalendarSidebarDrawer({
|
|||||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||||
<X size={20} color={colors.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>Calendars</Text>
|
<Text style={styles.headerTitle}>Calendars</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -134,15 +137,17 @@ function Section({
|
|||||||
hiddenSet: Set<string>;
|
hiddenSet: Set<string>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
{calendars.map((c) => {
|
{calendars.map((cal) => {
|
||||||
const visible = !hiddenSet.has(c.id);
|
const visible = !hiddenSet.has(cal.id);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={c.id}
|
key={cal.id}
|
||||||
onPress={() => onToggle(c.id)}
|
onPress={() => onToggle(cal.id)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.row,
|
styles.row,
|
||||||
pressed && styles.rowPressed,
|
pressed && styles.rowPressed,
|
||||||
@@ -152,15 +157,15 @@ function Section({
|
|||||||
style={[
|
style={[
|
||||||
styles.swatch,
|
styles.swatch,
|
||||||
{
|
{
|
||||||
backgroundColor: visible ? getCalendarColor(c) : 'transparent',
|
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
|
||||||
borderColor: getCalendarColor(c),
|
borderColor: getCalendarColor(cal),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{visible && <Check size={12} color={colors.textInverse} />}
|
{visible && <Check size={12} color={c.textInverse} />}
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
|
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
|
||||||
{c.name}
|
{cal.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -169,7 +174,8 @@ function Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
@@ -184,7 +190,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: 340,
|
maxWidth: 340,
|
||||||
backgroundColor: '#262626',
|
backgroundColor: '#262626',
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: colors.border,
|
borderRightColor: c.border,
|
||||||
},
|
},
|
||||||
drawerSafe: { flex: 1 },
|
drawerSafe: { flex: 1 },
|
||||||
|
|
||||||
@@ -195,7 +201,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
headerClose: {
|
headerClose: {
|
||||||
width: 36,
|
width: 36,
|
||||||
@@ -204,14 +210,14 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text },
|
headerTitle: { ...typography.h3, color: c.text },
|
||||||
|
|
||||||
scroll: { flex: 1 },
|
scroll: { flex: 1 },
|
||||||
scrollContent: { paddingBottom: spacing.lg },
|
scrollContent: { paddingBottom: spacing.lg },
|
||||||
|
|
||||||
empty: {
|
empty: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.lg,
|
paddingVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
@@ -219,7 +225,7 @@ const styles = StyleSheet.create({
|
|||||||
section: { paddingTop: spacing.md },
|
section: { paddingTop: spacing.md },
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.xs,
|
paddingBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
@@ -231,7 +237,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
},
|
},
|
||||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
swatch: {
|
swatch: {
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
@@ -240,6 +246,7 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
rowName: { flex: 1, ...typography.body, color: colors.text },
|
rowName: { flex: 1, ...typography.body, color: c.text },
|
||||||
rowNameMuted: { color: colors.textMuted },
|
rowNameMuted: { color: c.textMuted },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
|
|||||||
import { Clock, MapPin, Users } from 'lucide-react-native';
|
import { Clock, MapPin, Users } from 'lucide-react-native';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@@ -24,6 +25,8 @@ function formatTimeRange(event: CalendarEvent): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const color = getEventColor(event, calendars);
|
const color = getEventColor(event, calendars);
|
||||||
const time = formatTimeRange(event);
|
const time = formatTimeRange(event);
|
||||||
const count = participantCount(event);
|
const count = participantCount(event);
|
||||||
@@ -48,13 +51,13 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
|
|||||||
</View>
|
</View>
|
||||||
{!event.showWithoutTime && (
|
{!event.showWithoutTime && (
|
||||||
<View style={styles.detailRow}>
|
<View style={styles.detailRow}>
|
||||||
<Clock size={12} color={colors.textMuted} />
|
<Clock size={12} color={c.textMuted} />
|
||||||
<Text style={styles.detailText}>{time}</Text>
|
<Text style={styles.detailText}>{time}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{event.description ? (
|
{event.description ? (
|
||||||
<View style={styles.detailRow}>
|
<View style={styles.detailRow}>
|
||||||
<MapPin size={12} color={colors.textMuted} />
|
<MapPin size={12} color={c.textMuted} />
|
||||||
<Text style={styles.detailText} numberOfLines={1}>
|
<Text style={styles.detailText} numberOfLines={1}>
|
||||||
{event.description}
|
{event.description}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -62,7 +65,7 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
|
|||||||
) : null}
|
) : null}
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<View style={styles.detailRow}>
|
<View style={styles.detailRow}>
|
||||||
<Users size={12} color={colors.textMuted} />
|
<Users size={12} color={c.textMuted} />
|
||||||
<Text style={styles.detailText}>{count} participants</Text>
|
<Text style={styles.detailText}>{count} participants</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -71,17 +74,18 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
cardPressed: { backgroundColor: colors.surfaceHover },
|
cardPressed: { backgroundColor: c.surfaceHover },
|
||||||
colorBar: { width: 3 },
|
colorBar: { width: 3 },
|
||||||
body: { flex: 1, padding: spacing.md, gap: 4 },
|
body: { flex: 1, padding: spacing.md, gap: 4 },
|
||||||
headerRow: {
|
headerRow: {
|
||||||
@@ -90,14 +94,15 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
title: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||||
allDayBadge: {
|
allDayBadge: {
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
allDayText: { ...typography.small, color: colors.primary },
|
allDayText: { ...typography.small, color: c.primary },
|
||||||
detailRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
detailRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
detailText: { ...typography.caption, color: colors.textMuted, flexShrink: 1 },
|
detailText: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ import {
|
|||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import {
|
import {
|
||||||
eventTimeRange,
|
eventTimeRange,
|
||||||
getEventColor,
|
getEventColor,
|
||||||
getPrimaryCalendarId,
|
getPrimaryCalendarId,
|
||||||
} from '../../lib/calendar-utils';
|
} from '../../lib/calendar-utils';
|
||||||
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
|
|
||||||
interface EventDetailSheetProps {
|
interface EventDetailSheetProps {
|
||||||
event: CalendarEvent | null;
|
event: CalendarEvent | null;
|
||||||
@@ -106,9 +108,16 @@ export function EventDetailSheet({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
}: EventDetailSheetProps) {
|
}: EventDetailSheetProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
const visible = !!event;
|
const visible = !!event;
|
||||||
|
const dragHandlers = useSheetDrag({
|
||||||
|
slideY,
|
||||||
|
closedY: Dimensions.get('window').height,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -166,17 +175,21 @@ export function EventDetailSheet({
|
|||||||
|
|
||||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||||
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
|
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
|
||||||
<View style={styles.handle} />
|
<View {...dragHandlers}>
|
||||||
|
<View style={styles.handleHit}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
||||||
<View style={styles.headerText}>
|
<View style={styles.headerText}>
|
||||||
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
|
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
|
||||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||||
|
<X size={20} color={c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
|
||||||
<X size={20} color={colors.textMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -184,29 +197,29 @@ export function EventDetailSheet({
|
|||||||
contentContainerStyle={styles.bodyContent}
|
contentContainerStyle={styles.bodyContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<DetailRow icon={<Clock size={16} color={colors.textMuted} />} text={range} />
|
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
|
||||||
{event.description ? (
|
{event.description ? (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<AlignLeft size={16} color={colors.textMuted} />}
|
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||||
text={event.description}
|
text={event.description}
|
||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{recurrence && (
|
{recurrence && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<Repeat size={16} color={colors.textMuted} />}
|
icon={<Repeat size={16} color={c.textMuted} />}
|
||||||
text={recurrence}
|
text={recurrence}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{alert && (
|
{alert && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<Bell size={16} color={colors.textMuted} />}
|
icon={<Bell size={16} color={c.textMuted} />}
|
||||||
text={alert}
|
text={alert}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{calendar && (
|
{calendar && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<CalendarIcon size={16} color={colors.textMuted} />}
|
icon={<CalendarIcon size={16} color={c.textMuted} />}
|
||||||
text={calendar.name}
|
text={calendar.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -214,7 +227,7 @@ export function EventDetailSheet({
|
|||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
<View style={styles.participantsBlock}>
|
<View style={styles.participantsBlock}>
|
||||||
<View style={styles.participantsHeader}>
|
<View style={styles.participantsHeader}>
|
||||||
<Users size={16} color={colors.textMuted} />
|
<Users size={16} color={c.textMuted} />
|
||||||
<Text style={styles.participantsHeaderText}>
|
<Text style={styles.participantsHeaderText}>
|
||||||
{participants.length} participants
|
{participants.length} participants
|
||||||
</Text>
|
</Text>
|
||||||
@@ -225,7 +238,7 @@ export function EventDetailSheet({
|
|||||||
style={[
|
style={[
|
||||||
styles.participantDot,
|
styles.participantDot,
|
||||||
{
|
{
|
||||||
backgroundColor: statusColor(p.participationStatus),
|
backgroundColor: statusColor(c, p.participationStatus),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -244,21 +257,21 @@ export function EventDetailSheet({
|
|||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Pencil size={18} color={colors.text} />}
|
icon={<Pencil size={18} color={c.text} />}
|
||||||
label="Edit"
|
label="Edit"
|
||||||
onPress={() => onEdit(event)}
|
onPress={() => onEdit(event)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onDuplicate && (
|
{onDuplicate && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Copy size={18} color={colors.text} />}
|
icon={<Copy size={18} color={c.text} />}
|
||||||
label="Duplicate"
|
label="Duplicate"
|
||||||
onPress={() => onDuplicate(event)}
|
onPress={() => onDuplicate(event)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Trash2 size={18} color={colors.error} />}
|
icon={<Trash2 size={18} color={c.error} />}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
onPress={() => onDelete(event)}
|
onPress={() => onDelete(event)}
|
||||||
destructive
|
destructive
|
||||||
@@ -280,6 +293,8 @@ function DetailRow({
|
|||||||
text: string;
|
text: string;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.detailRow}>
|
<View style={styles.detailRow}>
|
||||||
<View style={styles.detailIcon}>{icon}</View>
|
<View style={styles.detailIcon}>{icon}</View>
|
||||||
@@ -301,6 +316,8 @@ function ActionButton({
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -317,16 +334,16 @@ function ActionButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status?: string): string {
|
function statusColor(c: ThemePalette, status?: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
return colors.success;
|
return c.success;
|
||||||
case 'declined':
|
case 'declined':
|
||||||
return colors.error;
|
return c.error;
|
||||||
case 'tentative':
|
case 'tentative':
|
||||||
return colors.warning;
|
return c.warning;
|
||||||
default:
|
default:
|
||||||
return colors.textMuted;
|
return c.textMuted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +362,8 @@ function statusLabel(status?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
@@ -357,20 +375,23 @@ const styles = StyleSheet.create({
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
maxHeight: '85%',
|
maxHeight: '85%',
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderTopLeftRadius: radius.xl,
|
borderTopLeftRadius: radius.xl,
|
||||||
borderTopRightRadius: radius.xl,
|
borderTopRightRadius: radius.xl,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
},
|
},
|
||||||
sheetSafe: { paddingTop: spacing.sm },
|
sheetSafe: { paddingTop: spacing.sm },
|
||||||
|
handleHit: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: spacing.xs,
|
||||||
|
paddingBottom: spacing.sm,
|
||||||
|
},
|
||||||
handle: {
|
handle: {
|
||||||
alignSelf: 'center',
|
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: colors.surfaceActive,
|
backgroundColor: c.surfaceActive,
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
@@ -382,8 +403,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
|
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
|
||||||
headerText: { flex: 1 },
|
headerText: { flex: 1 },
|
||||||
title: { ...typography.h3, color: colors.text },
|
title: { ...typography.h3, color: c.text },
|
||||||
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||||
closeBtn: { padding: 4 },
|
closeBtn: { padding: 4 },
|
||||||
|
|
||||||
body: { maxHeight: 400 },
|
body: { maxHeight: 400 },
|
||||||
@@ -394,7 +415,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
detailRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'flex-start' },
|
detailRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'flex-start' },
|
||||||
detailIcon: { width: 16, paddingTop: 2 },
|
detailIcon: { width: 16, paddingTop: 2 },
|
||||||
detailText: { flex: 1, ...typography.body, color: colors.text },
|
detailText: { flex: 1, ...typography.body, color: c.text },
|
||||||
|
|
||||||
participantsBlock: {
|
participantsBlock: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
@@ -406,7 +427,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
participantsHeaderText: { ...typography.bodyMedium, color: colors.text },
|
participantsHeaderText: { ...typography.bodyMedium, color: c.text },
|
||||||
participantRow: {
|
participantRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -414,13 +435,13 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
participantDot: { width: 8, height: 8, borderRadius: 4 },
|
participantDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
participantName: { flex: 1, ...typography.body, color: colors.text },
|
participantName: { flex: 1, ...typography.body, color: c.text },
|
||||||
participantStatus: { ...typography.caption, color: colors.textMuted },
|
participantStatus: { ...typography.caption, color: c.textMuted },
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.sm,
|
||||||
},
|
},
|
||||||
@@ -432,7 +453,8 @@ const styles = StyleSheet.create({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
actionBtnPressed: { backgroundColor: colors.surfaceHover },
|
actionBtnPressed: { backgroundColor: c.surfaceHover },
|
||||||
actionLabel: { ...typography.caption, color: colors.text },
|
actionLabel: { ...typography.caption, color: c.text },
|
||||||
actionLabelDestructive: { color: colors.error },
|
actionLabelDestructive: { color: c.error },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { addHours, addMinutes, format } from 'date-fns';
|
import { addHours, addMinutes, format } from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
|
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import {
|
import {
|
||||||
buildAllDayDuration,
|
buildAllDayDuration,
|
||||||
getCalendarColor,
|
getCalendarColor,
|
||||||
@@ -150,6 +151,8 @@ export function EventModal({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const isEdit = !!event;
|
const isEdit = !!event;
|
||||||
|
|
||||||
const [title, setTitle] = React.useState('');
|
const [title, setTitle] = React.useState('');
|
||||||
@@ -264,7 +267,7 @@ export function EventModal({
|
|||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
|
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
|
||||||
<X size={20} color={colors.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>{isEdit ? 'Edit event' : 'New event'}</Text>
|
<Text style={styles.headerTitle}>{isEdit ? 'Edit event' : 'New event'}</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -287,12 +290,12 @@ export function EventModal({
|
|||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.titleInput}
|
style={styles.titleInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
icon={<CalendarIcon size={16} color={colors.textMuted} />}
|
icon={<CalendarIcon size={16} color={c.textMuted} />}
|
||||||
label="Calendar"
|
label="Calendar"
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -340,14 +343,14 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={<Clock size={16} color={colors.textMuted} />} label="Time">
|
<Section icon={<Clock size={16} color={c.textMuted} />} label="Time">
|
||||||
<View style={styles.allDayRow}>
|
<View style={styles.allDayRow}>
|
||||||
<Text style={styles.fieldText}>All-day</Text>
|
<Text style={styles.fieldText}>All-day</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={allDay}
|
value={allDay}
|
||||||
onValueChange={setAllDay}
|
onValueChange={setAllDay}
|
||||||
thumbColor={allDay ? colors.primary : colors.textMuted}
|
thumbColor={allDay ? c.primary : c.textMuted}
|
||||||
trackColor={{ false: colors.surface, true: colors.primaryBg }}
|
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -392,7 +395,7 @@ export function EventModal({
|
|||||||
</View>
|
</View>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={<Repeat size={16} color={colors.textMuted} />} label="Repeat">
|
<Section icon={<Repeat size={16} color={c.textMuted} />} label="Repeat">
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.fieldButton}
|
style={styles.fieldButton}
|
||||||
onPress={() => setRecurrenceOpen((v) => !v)}
|
onPress={() => setRecurrenceOpen((v) => !v)}
|
||||||
@@ -420,7 +423,7 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={<Bell size={16} color={colors.textMuted} />} label="Reminder">
|
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.fieldButton}
|
style={styles.fieldButton}
|
||||||
onPress={() => setAlertOpen((v) => !v)}
|
onPress={() => setAlertOpen((v) => !v)}
|
||||||
@@ -449,21 +452,21 @@ export function EventModal({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
icon={<MapPin size={16} color={colors.textMuted} />}
|
icon={<MapPin size={16} color={c.textMuted} />}
|
||||||
label="Description"
|
label="Description"
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={description}
|
value={description}
|
||||||
onChangeText={setDescription}
|
onChangeText={setDescription}
|
||||||
placeholder="Notes, location, link…"
|
placeholder="Notes, location, link…"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
multiline
|
multiline
|
||||||
style={styles.descriptionInput}
|
style={styles.descriptionInput}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
icon={<Users size={16} color={colors.textMuted} />}
|
icon={<Users size={16} color={c.textMuted} />}
|
||||||
label="Participants"
|
label="Participants"
|
||||||
>
|
>
|
||||||
<ParticipantInput
|
<ParticipantInput
|
||||||
@@ -480,7 +483,7 @@ export function EventModal({
|
|||||||
]}
|
]}
|
||||||
onPress={handleDelete}
|
onPress={handleDelete}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} color={colors.error} />
|
<Trash2 size={16} color={c.error} />
|
||||||
<Text style={styles.deleteBtnText}>Delete event</Text>
|
<Text style={styles.deleteBtnText}>Delete event</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -560,6 +563,8 @@ function Section({
|
|||||||
label: string;
|
label: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
@@ -571,15 +576,16 @@ function Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
@@ -589,23 +595,23 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { flex: 1, ...typography.h3, color: colors.text },
|
headerTitle: { flex: 1, ...typography.h3, color: c.text },
|
||||||
|
|
||||||
body: { flex: 1 },
|
body: { flex: 1 },
|
||||||
bodyContent: { padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xxxl },
|
bodyContent: { padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xxxl },
|
||||||
|
|
||||||
titleInput: {
|
titleInput: {
|
||||||
...typography.h2,
|
...typography.h2,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
|
|
||||||
section: { gap: spacing.sm },
|
section: { gap: spacing.sm },
|
||||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
sectionIcon: { width: 16 },
|
sectionIcon: { width: 16 },
|
||||||
sectionLabel: { ...typography.bodyMedium, color: colors.textSecondary },
|
sectionLabel: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
sectionBody: { gap: spacing.sm },
|
sectionBody: { gap: spacing.sm },
|
||||||
|
|
||||||
fieldButton: {
|
fieldButton: {
|
||||||
@@ -614,20 +620,20 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
fieldText: { ...typography.body, color: colors.text },
|
fieldText: { ...typography.body, color: c.text },
|
||||||
|
|
||||||
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
|
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
|
||||||
|
|
||||||
popover: {
|
popover: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
popoverRow: {
|
popoverRow: {
|
||||||
@@ -637,10 +643,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
popoverRowActive: { backgroundColor: colors.primaryBg },
|
popoverRowActive: { backgroundColor: c.primaryBg },
|
||||||
popoverRowText: { ...typography.body, color: colors.text },
|
popoverRowText: { ...typography.body, color: c.text },
|
||||||
|
|
||||||
allDayRow: {
|
allDayRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -649,9 +655,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
dateRow: {
|
dateRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -660,20 +666,20 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
dateLabel: { ...typography.body, color: colors.textSecondary, flex: 1 },
|
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||||
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
||||||
dateBtn: {
|
dateBtn: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
dateText: { ...typography.body, color: colors.text },
|
dateText: { ...typography.body, color: c.text },
|
||||||
|
|
||||||
descriptionInput: {
|
descriptionInput: {
|
||||||
minHeight: 80,
|
minHeight: 80,
|
||||||
@@ -681,9 +687,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
textAlignVertical: 'top',
|
textAlignVertical: 'top',
|
||||||
},
|
},
|
||||||
@@ -696,9 +702,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.errorBorder,
|
borderColor: c.errorBorder,
|
||||||
marginTop: spacing.lg,
|
marginTop: spacing.lg,
|
||||||
},
|
},
|
||||||
deleteBtnPressed: { backgroundColor: colors.errorBg },
|
deleteBtnPressed: { backgroundColor: c.errorBg },
|
||||||
deleteBtnText: { ...typography.bodyMedium, color: colors.error },
|
deleteBtnText: { ...typography.bodyMedium, color: c.error },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
startOfWeek,
|
startOfWeek,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
import { colors, componentSizes, radius, spacing, typography } from '../../theme/tokens';
|
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import {
|
import {
|
||||||
buildEventDayIndex,
|
buildEventDayIndex,
|
||||||
eventsOnDayFromIndex,
|
eventsOnDayFromIndex,
|
||||||
@@ -32,7 +33,7 @@ interface MonthViewProps {
|
|||||||
onLongPressDate?: (date: Date) => void;
|
onLongPressDate?: (date: Date) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonthView({
|
function MonthViewInner({
|
||||||
currentDate,
|
currentDate,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
@@ -41,6 +42,8 @@ export function MonthView({
|
|||||||
onSelectDate,
|
onSelectDate,
|
||||||
onLongPressDate,
|
onLongPressDate,
|
||||||
}: MonthViewProps) {
|
}: MonthViewProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const index = React.useMemo(
|
const index = React.useMemo(
|
||||||
() => eventsByDay ?? buildEventDayIndex(events),
|
() => eventsByDay ?? buildEventDayIndex(events),
|
||||||
[eventsByDay, events],
|
[eventsByDay, events],
|
||||||
@@ -118,14 +121,17 @@ export function MonthView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
export const MonthView = React.memo(MonthViewInner);
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return 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 },
|
||||||
weekdayLabel: {
|
weekdayLabel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||||
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
|
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
|
||||||
@@ -136,12 +142,12 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
dayText: { ...typography.body, color: colors.text },
|
dayText: { ...typography.body, color: c.text },
|
||||||
dayTextMuted: { color: colors.textMuted },
|
dayTextMuted: { color: c.textMuted },
|
||||||
todayCircle: { backgroundColor: colors.primary },
|
todayCircle: { backgroundColor: c.primary },
|
||||||
todayText: { color: colors.textInverse, fontWeight: '700' },
|
todayText: { color: c.textInverse, fontWeight: '700' },
|
||||||
selectedCircle: { backgroundColor: colors.primaryBg, borderWidth: 1.5, borderColor: colors.primary },
|
selectedCircle: { backgroundColor: c.primaryBg, borderWidth: 1.5, borderColor: c.primary },
|
||||||
selectedText: { color: colors.primary, fontWeight: '600' },
|
selectedText: { color: c.primary, fontWeight: '600' },
|
||||||
dotsRow: {
|
dotsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -158,9 +164,10 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: componentSizes.eventDot / 2,
|
borderRadius: componentSizes.eventDot / 2,
|
||||||
},
|
},
|
||||||
overflowText: {
|
overflowText: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
lineHeight: 10,
|
lineHeight: 10,
|
||||||
marginLeft: 1,
|
marginLeft: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { X } from 'lucide-react-native';
|
import { X } from 'lucide-react-native';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useContactsStore } from '../../stores/contacts-store';
|
import { useContactsStore } from '../../stores/contacts-store';
|
||||||
import type { Participant } from '../../api/types';
|
import type { Participant } from '../../api/types';
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ function flattenContactEmails(): Suggestion[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
|
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [draft, setDraft] = React.useState('');
|
const [draft, setDraft] = React.useState('');
|
||||||
const [allSuggestions] = React.useState(() => flattenContactEmails());
|
const [allSuggestions] = React.useState(() => flattenContactEmails());
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
|||||||
{p.name || p.email || 'Unknown'}
|
{p.name || p.email || 'Unknown'}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
|
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
|
||||||
<X size={12} color={colors.textMuted} />
|
<X size={12} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -121,7 +124,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChangeText={setDraft}
|
onChangeText={setDraft}
|
||||||
placeholder="Add participant by email"
|
placeholder="Add participant by email"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
@@ -155,45 +158,47 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.sm },
|
container: { gap: spacing.sm },
|
||||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
},
|
},
|
||||||
chipText: { ...typography.caption, color: colors.text, maxWidth: 200 },
|
chipText: { ...typography.caption, color: c.text, maxWidth: 200 },
|
||||||
input: {
|
input: {
|
||||||
height: 40,
|
height: 40,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
suggestions: {
|
suggestions: {
|
||||||
maxHeight: 180,
|
maxHeight: 180,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
suggestionRow: {
|
suggestionRow: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
suggestionRowPressed: { backgroundColor: colors.surfaceHover },
|
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
||||||
suggestionName: { ...typography.bodyMedium, color: colors.text },
|
suggestionName: { ...typography.bodyMedium, color: c.text },
|
||||||
suggestionEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
suggestionEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
|
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
|
||||||
import { Repeat, Trash2 } from 'lucide-react-native';
|
import { Repeat, Trash2 } from 'lucide-react-native';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { Button } from '..';
|
import { Button } from '..';
|
||||||
|
|
||||||
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
|
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
|
||||||
@@ -25,6 +26,8 @@ export function RecurrenceScopeDialog({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
}: RecurrenceScopeDialogProps) {
|
}: RecurrenceScopeDialogProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
|
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
|
||||||
const isDelete = actionType === 'delete';
|
const isDelete = actionType === 'delete';
|
||||||
|
|
||||||
@@ -39,9 +42,9 @@ export function RecurrenceScopeDialog({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.iconBadge, isDelete ? styles.iconBadgeDanger : styles.iconBadgePrimary]}>
|
<View style={[styles.iconBadge, isDelete ? styles.iconBadgeDanger : styles.iconBadgePrimary]}>
|
||||||
{isDelete ? (
|
{isDelete ? (
|
||||||
<Trash2 size={20} color={colors.error} />
|
<Trash2 size={20} color={c.error} />
|
||||||
) : (
|
) : (
|
||||||
<Repeat size={20} color={colors.primary} />
|
<Repeat size={20} color={c.primary} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headerText}>
|
<View style={styles.headerText}>
|
||||||
@@ -90,7 +93,8 @@ export function RecurrenceScopeDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
@@ -101,9 +105,9 @@ const styles = StyleSheet.create({
|
|||||||
dialog: {
|
dialog: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
padding: spacing.xxl,
|
padding: spacing.xxl,
|
||||||
gap: spacing.lg,
|
gap: spacing.lg,
|
||||||
@@ -116,11 +120,11 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
iconBadgeDanger: { backgroundColor: colors.errorBg },
|
iconBadgeDanger: { backgroundColor: c.errorBg },
|
||||||
iconBadgePrimary: { backgroundColor: colors.primaryBg },
|
iconBadgePrimary: { backgroundColor: c.primaryBg },
|
||||||
headerText: { flex: 1 },
|
headerText: { flex: 1 },
|
||||||
title: { ...typography.h3, color: colors.text },
|
title: { ...typography.h3, color: c.text },
|
||||||
description: { ...typography.body, color: colors.textMuted, marginTop: 4 },
|
description: { ...typography.body, color: c.textMuted, marginTop: 4 },
|
||||||
options: { gap: spacing.xs },
|
options: { gap: spacing.xs },
|
||||||
option: {
|
option: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -132,27 +136,28 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
},
|
},
|
||||||
optionActive: { backgroundColor: colors.primaryBg, borderColor: colors.primary },
|
optionActive: { backgroundColor: c.primaryBg, borderColor: c.primary },
|
||||||
radio: {
|
radio: {
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
borderRadius: 9,
|
borderRadius: 9,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
radioActive: { borderColor: colors.primary },
|
radioActive: { borderColor: c.primary },
|
||||||
radioDot: {
|
radioDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
optionLabel: { ...typography.body, color: colors.text, flex: 1 },
|
optionLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns';
|
import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns';
|
||||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import {
|
import {
|
||||||
buildEventDayIndex,
|
buildEventDayIndex,
|
||||||
buildTimedFullDayWeekSegments,
|
buildTimedFullDayWeekSegments,
|
||||||
@@ -39,7 +40,7 @@ interface WeekViewProps {
|
|||||||
weekStartsOn?: 0 | 1;
|
weekStartsOn?: 0 | 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekView({
|
function WeekViewInner({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
calendars,
|
calendars,
|
||||||
@@ -49,6 +50,8 @@ export function WeekView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
weekStartsOn = 0,
|
weekStartsOn = 0,
|
||||||
}: WeekViewProps) {
|
}: WeekViewProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const scrollRef = React.useRef<ScrollView>(null);
|
const scrollRef = React.useRef<ScrollView>(null);
|
||||||
|
|
||||||
const index = React.useMemo(
|
const index = React.useMemo(
|
||||||
@@ -303,18 +306,21 @@ 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;
|
||||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
gutter: { width: GUTTER_WIDTH },
|
gutter: { width: GUTTER_WIDTH },
|
||||||
dayHeaders: { flex: 1, flexDirection: 'row' },
|
dayHeaders: { flex: 1, flexDirection: 'row' },
|
||||||
@@ -326,7 +332,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
dayHeaderWeekday: {
|
dayHeaderWeekday: {
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
dayHeaderNumber: {
|
dayHeaderNumber: {
|
||||||
@@ -336,29 +342,29 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
dayHeaderNumberToday: { backgroundColor: colors.primary },
|
dayHeaderNumberToday: { backgroundColor: c.primary },
|
||||||
dayHeaderNumberSelected: {
|
dayHeaderNumberSelected: {
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.primary,
|
borderColor: c.primary,
|
||||||
},
|
},
|
||||||
dayHeaderNumberText: { ...typography.bodyMedium, color: colors.text },
|
dayHeaderNumberText: { ...typography.bodyMedium, color: c.text },
|
||||||
dayHeaderNumberTextToday: { color: colors.textInverse, fontWeight: '700' },
|
dayHeaderNumberTextToday: { color: c.textInverse, fontWeight: '700' },
|
||||||
dayHeaderNumberTextSelected: { color: colors.primary, fontWeight: '600' },
|
dayHeaderNumberTextSelected: { color: c.primary, fontWeight: '600' },
|
||||||
|
|
||||||
allDayStrip: {
|
allDayStrip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
|
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
|
||||||
allDayLabel: { ...typography.small, color: colors.textMuted },
|
allDayLabel: { ...typography.small, color: c.textMuted },
|
||||||
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
|
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
|
||||||
allDayCol: {
|
allDayCol: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderLeftColor: colors.borderLight,
|
borderLeftColor: c.borderLight,
|
||||||
},
|
},
|
||||||
allDayOverlay: {
|
allDayOverlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -374,7 +380,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
allDayChipText: { fontSize: 10, color: colors.text, fontWeight: '500' },
|
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
|
||||||
|
|
||||||
scroll: { flex: 1 },
|
scroll: { flex: 1 },
|
||||||
scrollContent: {},
|
scrollContent: {},
|
||||||
@@ -383,19 +389,19 @@ const styles = StyleSheet.create({
|
|||||||
hourLabelCell: { paddingRight: 4, alignItems: 'flex-end' },
|
hourLabelCell: { paddingRight: 4, alignItems: 'flex-end' },
|
||||||
hourLabel: {
|
hourLabel: {
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
transform: [{ translateY: -6 }],
|
transform: [{ translateY: -6 }],
|
||||||
},
|
},
|
||||||
dayCols: { flex: 1, flexDirection: 'row' },
|
dayCols: { flex: 1, flexDirection: 'row' },
|
||||||
dayCol: {
|
dayCol: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderLeftColor: colors.border,
|
borderLeftColor: c.border,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
hourSlot: {
|
hourSlot: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
eventBlock: {
|
eventBlock: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -405,8 +411,8 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
eventBlockTitle: { fontSize: 10, color: colors.text, fontWeight: '600' },
|
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
|
||||||
eventBlockTime: { fontSize: 9, color: colors.textMuted, marginTop: 1 },
|
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
|
||||||
|
|
||||||
nowLine: {
|
nowLine: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -419,7 +425,8 @@ const styles = StyleSheet.create({
|
|||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
backgroundColor: colors.error,
|
backgroundColor: c.error,
|
||||||
},
|
},
|
||||||
nowBar: { flex: 1, height: 1, backgroundColor: colors.error },
|
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { RootStackParamList } from '../../navigation/types';
|
|||||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||||
import { useCalendarStore } from '../../stores/calendar-store';
|
import { useCalendarStore } from '../../stores/calendar-store';
|
||||||
import SenderAvatar from '../SenderAvatar';
|
import SenderAvatar from '../SenderAvatar';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
const EMAIL_LIMIT = 5;
|
const EMAIL_LIMIT = 5;
|
||||||
const EVENT_LIMIT = 5;
|
const EVENT_LIMIT = 5;
|
||||||
@@ -82,6 +83,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContactActivity({ contact }: Props) {
|
export function ContactActivity({ contact }: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const calendarEvents = useCalendarStore((s) => s.events);
|
const calendarEvents = useCalendarStore((s) => s.events);
|
||||||
|
|
||||||
@@ -142,12 +145,12 @@ export function ContactActivity({ contact }: Props) {
|
|||||||
<View style={styles.wrap}>
|
<View style={styles.wrap}>
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<MailIcon size={14} color={colors.primary} />
|
<MailIcon size={14} color={c.primary} />
|
||||||
<Text style={styles.sectionTitle}>Recent emails</Text>
|
<Text style={styles.sectionTitle}>Recent emails</Text>
|
||||||
</View>
|
</View>
|
||||||
{emailsLoading ? (
|
{emailsLoading ? (
|
||||||
<View style={styles.loading}>
|
<View style={styles.loading}>
|
||||||
<ActivityIndicator size="small" color={colors.primary} />
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
) : emailsError ? (
|
) : emailsError ? (
|
||||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||||
@@ -190,7 +193,7 @@ export function ContactActivity({ contact }: Props) {
|
|||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<CalendarDays size={14} color={colors.primary} />
|
<CalendarDays size={14} color={c.primary} />
|
||||||
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
||||||
</View>
|
</View>
|
||||||
{upcomingEvents.length === 0 ? (
|
{upcomingEvents.length === 0 ? (
|
||||||
@@ -215,21 +218,22 @@ export function ContactActivity({ contact }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
||||||
section: {
|
section: {
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
borderLeftWidth: 3,
|
borderLeftWidth: 3,
|
||||||
borderLeftColor: colors.primary,
|
borderLeftColor: c.primary,
|
||||||
},
|
},
|
||||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||||
sectionTitle: { ...typography.bodySemibold, color: colors.text },
|
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||||
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
||||||
emptyText: { ...typography.caption, color: colors.textMuted },
|
emptyText: { ...typography.caption, color: c.textMuted },
|
||||||
|
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -238,21 +242,22 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
},
|
},
|
||||||
rowPressed: { backgroundColor: colors.surface },
|
rowPressed: { backgroundColor: c.surface },
|
||||||
rowContent: { flex: 1, minWidth: 0 },
|
rowContent: { flex: 1, minWidth: 0 },
|
||||||
rowTop: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm },
|
rowTop: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm },
|
||||||
rowSender: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
rowSender: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||||
rowDate: { ...typography.caption, color: colors.textMuted },
|
rowDate: { ...typography.caption, color: c.textMuted },
|
||||||
rowSubject: { ...typography.body, color: colors.text },
|
rowSubject: { ...typography.body, color: c.text },
|
||||||
rowPreview: { ...typography.caption, color: colors.textMuted },
|
rowPreview: { ...typography.caption, color: c.textMuted },
|
||||||
|
|
||||||
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
||||||
eventTime: {
|
eventTime: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
width: 64,
|
width: 64,
|
||||||
},
|
},
|
||||||
eventInfo: { flex: 1, minWidth: 0 },
|
eventInfo: { flex: 1, minWidth: 0 },
|
||||||
eventTitle: { ...typography.body, color: colors.text },
|
eventTitle: { ...typography.body, color: c.text },
|
||||||
eventDate: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
eventDate: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
isGroup,
|
isGroup,
|
||||||
} from '../../lib/contact-utils';
|
} from '../../lib/contact-utils';
|
||||||
import SenderAvatar from '../SenderAvatar';
|
import SenderAvatar from '../SenderAvatar';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface ContactListRowProps {
|
interface ContactListRowProps {
|
||||||
contact: ContactCard;
|
contact: ContactCard;
|
||||||
@@ -29,6 +30,8 @@ export default function ContactListRow({
|
|||||||
selectionMode,
|
selectionMode,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
}: ContactListRowProps) {
|
}: ContactListRowProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const name = getContactDisplayName(contact) || 'Unnamed';
|
const name = getContactDisplayName(contact) || 'Unnamed';
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
const org = getPrimaryOrg(contact);
|
const org = getPrimaryOrg(contact);
|
||||||
@@ -50,7 +53,7 @@ export default function ContactListRow({
|
|||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<View style={[styles.checkbox, selected && styles.checkboxSelected]}>
|
<View style={[styles.checkbox, selected && styles.checkboxSelected]}>
|
||||||
{selected && <Check size={14} color={colors.primaryForeground} />}
|
{selected && <Check size={14} color={c.primaryForeground} />}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ export default function ContactListRow({
|
|||||||
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
|
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
|
||||||
) : group ? (
|
) : group ? (
|
||||||
<View style={styles.groupAvatar}>
|
<View style={styles.groupAvatar}>
|
||||||
<Users size={18} color={colors.primary} />
|
<Users size={18} color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} />
|
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} />
|
||||||
@@ -77,7 +80,8 @@ export default function ContactListRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -85,37 +89,38 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
rowSelected: { backgroundColor: colors.primaryBg },
|
rowSelected: { backgroundColor: c.primaryBg },
|
||||||
checkbox: {
|
checkbox: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
checkboxSelected: {
|
checkboxSelected: {
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
borderColor: colors.primary,
|
borderColor: c.primary,
|
||||||
},
|
},
|
||||||
avatarImage: {
|
avatarImage: {
|
||||||
width: componentSizes.avatarMd,
|
width: componentSizes.avatarMd,
|
||||||
height: componentSizes.avatarMd,
|
height: componentSizes.avatarMd,
|
||||||
borderRadius: componentSizes.avatarMd / 2,
|
borderRadius: componentSizes.avatarMd / 2,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
groupAvatar: {
|
groupAvatar: {
|
||||||
width: componentSizes.avatarMd,
|
width: componentSizes.avatarMd,
|
||||||
height: componentSizes.avatarMd,
|
height: componentSizes.avatarMd,
|
||||||
borderRadius: componentSizes.avatarMd / 2,
|
borderRadius: componentSizes.avatarMd / 2,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
info: { flex: 1, minWidth: 0 },
|
info: { flex: 1, minWidth: 0 },
|
||||||
name: { ...typography.bodyMedium, color: colors.text },
|
name: { ...typography.bodyMedium, color: c.text },
|
||||||
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||||
tertiary: { ...typography.small, color: colors.textSecondary, marginTop: 1 },
|
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
} from '../../stores/contacts-store';
|
} from '../../stores/contacts-store';
|
||||||
import { matchesContactSearch, isGroup } from '../../lib/contact-utils';
|
import { matchesContactSearch, isGroup } from '../../lib/contact-utils';
|
||||||
import ContactListRow from './ContactListRow';
|
import ContactListRow from './ContactListRow';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -26,9 +28,11 @@ interface Props {
|
|||||||
export default function ContactPickerSheet({
|
export default function ContactPickerSheet({
|
||||||
visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true,
|
visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const allContacts = useContactsStore((s) => s.contacts);
|
const allContacts = useContactsStore((s) => s.contacts);
|
||||||
const sorted = React.useMemo(
|
const sorted = React.useMemo(
|
||||||
() => sortContactsByDisplayName(allContacts.filter((c) => !isGroup(c))),
|
() => sortContactsByDisplayName(allContacts.filter((contact) => !isGroup(contact))),
|
||||||
[allContacts],
|
[allContacts],
|
||||||
);
|
);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
@@ -36,6 +40,11 @@ export default function ContactPickerSheet({
|
|||||||
|
|
||||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||||
const overlay = React.useRef(new Animated.Value(0)).current;
|
const overlay = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const dragHandlers = useSheetDrag({
|
||||||
|
slideY,
|
||||||
|
closedY: Dimensions.get('window').height,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -90,37 +99,41 @@ export default function ContactPickerSheet({
|
|||||||
|
|
||||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||||
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
|
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
|
||||||
<View style={styles.handle} />
|
<View {...dragHandlers}>
|
||||||
<View style={styles.header}>
|
<View style={styles.handleHit}>
|
||||||
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
|
<View style={styles.handle} />
|
||||||
<X size={20} color={colors.text} />
|
</View>
|
||||||
</Pressable>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>{title}</Text>
|
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
|
||||||
{multi && (
|
<X size={20} color={c.text} />
|
||||||
<Pressable
|
|
||||||
onPress={confirm}
|
|
||||||
disabled={selected.size === 0}
|
|
||||||
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
|
|
||||||
hitSlop={8}
|
|
||||||
>
|
|
||||||
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
<Text style={styles.headerTitle}>{title}</Text>
|
||||||
|
{multi && (
|
||||||
|
<Pressable
|
||||||
|
onPress={confirm}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.searchBar}>
|
<View style={styles.searchBar}>
|
||||||
<Search size={16} color={colors.textMuted} />
|
<Search size={16} color={c.textMuted} />
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
placeholder="Search contacts..."
|
placeholder="Search contacts..."
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={query}
|
value={query}
|
||||||
onChangeText={setQuery}
|
onChangeText={setQuery}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{query.length > 0 && (
|
{query.length > 0 && (
|
||||||
<Pressable onPress={() => setQuery('')} hitSlop={8}>
|
<Pressable onPress={() => setQuery('')} hitSlop={8}>
|
||||||
<X size={16} color={colors.textMuted} />
|
<X size={16} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -150,24 +163,28 @@ export default function ContactPickerSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
overlayPress: { flex: 1 },
|
overlayPress: { flex: 1 },
|
||||||
sheet: {
|
sheet: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0, right: 0, bottom: 0,
|
left: 0, right: 0, bottom: 0,
|
||||||
top: '15%',
|
top: '15%',
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderTopLeftRadius: radius.lg,
|
borderTopLeftRadius: radius.lg,
|
||||||
borderTopRightRadius: radius.lg,
|
borderTopRightRadius: radius.lg,
|
||||||
},
|
},
|
||||||
|
handleHit: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
handle: {
|
handle: {
|
||||||
alignSelf: 'center',
|
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: colors.border,
|
backgroundColor: c.border,
|
||||||
marginTop: spacing.sm,
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -176,23 +193,23 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
width: 40, height: 40,
|
width: 40, height: 40,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
|
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
|
||||||
doneBtn: {
|
doneBtn: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
height: 36,
|
height: 36,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
doneBtnDisabled: { opacity: 0.5 },
|
doneBtnDisabled: { opacity: 0.5 },
|
||||||
doneLabel: { ...typography.bodyMedium, color: colors.primaryForeground },
|
doneLabel: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||||
|
|
||||||
searchBar: {
|
searchBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -202,14 +219,15 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
height: componentSizes.inputHeight,
|
height: componentSizes.inputHeight,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
|
||||||
|
|
||||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||||
|
|
||||||
empty: { paddingVertical: spacing.xxxl, alignItems: 'center' },
|
empty: { paddingVertical: spacing.xxxl, alignItems: 'center' },
|
||||||
emptyText: { ...typography.body, color: colors.textMuted },
|
emptyText: { ...typography.body, color: c.textMuted },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
import type { ContactCategory } from '../../stores/contacts-store';
|
import type { ContactCategory } from '../../stores/contacts-store';
|
||||||
import { useContactsStore } from '../../stores/contacts-store';
|
import { useContactsStore } from '../../stores/contacts-store';
|
||||||
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
|
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -25,6 +26,8 @@ function isSameCategory(a: ContactCategory, b: ContactCategory): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||||
const contacts = useContactsStore((s) => s.contacts);
|
const contacts = useContactsStore((s) => s.contacts);
|
||||||
@@ -93,14 +96,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||||
<X size={20} color={colors.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>Contacts</Text>
|
<Text style={styles.headerTitle}>Contacts</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
icon={<Inbox size={16} color={colors.primary} />}
|
icon={<Inbox size={16} color={c.primary} />}
|
||||||
label="All Contacts"
|
label="All Contacts"
|
||||||
count={totalCount}
|
count={totalCount}
|
||||||
active={selectedCategory.type === 'all'}
|
active={selectedCategory.type === 'all'}
|
||||||
@@ -115,7 +118,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
{expanded.books && books.map((book) => (
|
{expanded.books && books.map((book) => (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
key={book.id}
|
key={book.id}
|
||||||
icon={<BookUser size={16} color={colors.textSecondary} />}
|
icon={<BookUser size={16} color={c.textSecondary} />}
|
||||||
label={book.name}
|
label={book.name}
|
||||||
count={book.count}
|
count={book.count}
|
||||||
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
|
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
|
||||||
@@ -124,7 +127,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
))}
|
))}
|
||||||
{expanded.books && (
|
{expanded.books && (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
icon={<BookUser size={16} color={colors.textMuted} />}
|
icon={<BookUser size={16} color={c.textMuted} />}
|
||||||
label="Uncategorized"
|
label="Uncategorized"
|
||||||
count={0}
|
count={0}
|
||||||
active={selectedCategory.type === 'uncategorized'}
|
active={selectedCategory.type === 'uncategorized'}
|
||||||
@@ -142,7 +145,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
{expanded.groups && groups.map((g) => (
|
{expanded.groups && groups.map((g) => (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
key={g.id}
|
key={g.id}
|
||||||
icon={<Users size={16} color={colors.textSecondary} />}
|
icon={<Users size={16} color={c.textSecondary} />}
|
||||||
label={getContactDisplayName(g) || 'Group'}
|
label={getContactDisplayName(g) || 'Group'}
|
||||||
count={g.members ? Object.keys(g.members).length : 0}
|
count={g.members ? Object.keys(g.members).length : 0}
|
||||||
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
|
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
|
||||||
@@ -160,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
{expanded.tags && keywords.map((kw) => (
|
{expanded.tags && keywords.map((kw) => (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
key={kw.keyword}
|
key={kw.keyword}
|
||||||
icon={<Tag size={16} color={colors.textSecondary} />}
|
icon={<Tag size={16} color={c.textSecondary} />}
|
||||||
label={kw.keyword}
|
label={kw.keyword}
|
||||||
count={kw.count}
|
count={kw.count}
|
||||||
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })}
|
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })}
|
||||||
@@ -175,12 +178,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({ label, expanded, onPress }: { label: string; expanded: boolean; onPress: () => void }) {
|
function SectionHeader({ label, expanded, onPress }: { label: string; expanded: boolean; onPress: () => void }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable style={styles.sectionHeader} onPress={onPress}>
|
<Pressable style={styles.sectionHeader} onPress={onPress}>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown size={14} color={colors.textMuted} />
|
<ChevronDown size={14} color={c.textMuted} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight size={14} color={colors.textMuted} />
|
<ChevronRight size={14} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.sectionHeaderText}>{label}</Text>
|
<Text style={styles.sectionHeaderText}>{label}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -196,6 +201,8 @@ function CategoryRow({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -212,7 +219,8 @@ function CategoryRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
overlayPress: { flex: 1 },
|
overlayPress: { flex: 1 },
|
||||||
drawer: {
|
drawer: {
|
||||||
@@ -222,7 +230,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: 340,
|
maxWidth: 340,
|
||||||
backgroundColor: '#262626',
|
backgroundColor: '#262626',
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: colors.border,
|
borderRightColor: c.border,
|
||||||
},
|
},
|
||||||
drawerSafe: { flex: 1 },
|
drawerSafe: { flex: 1 },
|
||||||
|
|
||||||
@@ -233,14 +241,14 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
headerClose: {
|
headerClose: {
|
||||||
width: 36, height: 36,
|
width: 36, height: 36,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text },
|
headerTitle: { ...typography.h3, color: c.text },
|
||||||
|
|
||||||
scroll: { flex: 1 },
|
scroll: { flex: 1 },
|
||||||
scrollContent: { paddingBottom: spacing.md },
|
scrollContent: { paddingBottom: spacing.md },
|
||||||
@@ -253,7 +261,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: spacing.md,
|
paddingTop: spacing.md,
|
||||||
paddingBottom: 4,
|
paddingBottom: 4,
|
||||||
},
|
},
|
||||||
sectionHeaderText: { ...typography.bodySemibold, color: colors.text },
|
sectionHeaderText: { ...typography.bodySemibold, color: c.text },
|
||||||
|
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -263,14 +271,15 @@ const styles = StyleSheet.create({
|
|||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
borderLeftColor: 'transparent',
|
borderLeftColor: 'transparent',
|
||||||
},
|
},
|
||||||
rowPressed: { backgroundColor: colors.surfaceHover },
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
rowActive: { backgroundColor: colors.accent, borderLeftColor: colors.primary },
|
rowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
|
||||||
rowIcon: {
|
rowIcon: {
|
||||||
width: 20, height: 20,
|
width: 20, height: 20,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
rowLabel: { flex: 1, ...typography.body, color: colors.text },
|
rowLabel: { flex: 1, ...typography.body, color: c.text },
|
||||||
rowLabelActive: { ...typography.bodySemibold, color: colors.text },
|
rowLabelActive: { ...typography.bodySemibold, color: c.text },
|
||||||
rowCount: { ...typography.caption, color: colors.textMuted, marginLeft: spacing.sm },
|
rowCount: { ...typography.caption, color: c.textMuted, marginLeft: spacing.sm },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
import { Plus, X } from 'lucide-react-native';
|
import { Plus, X } from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
export type CategoryAccent = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'calendar' | 'notes';
|
export type CategoryAccent = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'calendar' | 'notes';
|
||||||
|
|
||||||
const ACCENT_COLORS: Record<CategoryAccent, string> = {
|
function accentColor(category: CategoryAccent, c: ThemePalette): string {
|
||||||
contact: colors.primary,
|
switch (category) {
|
||||||
work: colors.calendar.orange,
|
case 'contact': return c.primary;
|
||||||
location: colors.calendar.green,
|
case 'work': return c.calendar.orange;
|
||||||
personal: colors.calendar.pink,
|
case 'location': return c.calendar.green;
|
||||||
digital: colors.calendar.teal,
|
case 'personal': return c.calendar.pink;
|
||||||
calendar: colors.calendar.indigo,
|
case 'digital': return c.calendar.teal;
|
||||||
notes: colors.calendar.purple,
|
case 'calendar': return c.calendar.indigo;
|
||||||
};
|
case 'notes': return c.calendar.purple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface FieldBlockProps {
|
interface FieldBlockProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,7 +30,9 @@ interface FieldBlockProps {
|
|||||||
export default function FieldBlock({
|
export default function FieldBlock({
|
||||||
title, category = 'contact', icon, onAdd, addLabel, children,
|
title, category = 'contact', icon, onAdd, addLabel, children,
|
||||||
}: FieldBlockProps) {
|
}: FieldBlockProps) {
|
||||||
const accent = ACCENT_COLORS[category];
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const accent = accentColor(category, c);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.block, { borderLeftColor: accent }]}>
|
<View style={[styles.block, { borderLeftColor: accent }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -36,7 +41,7 @@ export default function FieldBlock({
|
|||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<Pressable onPress={onAdd} hitSlop={8} style={styles.addBtn}>
|
<Pressable onPress={onAdd} hitSlop={8} style={styles.addBtn}>
|
||||||
<Plus size={14} color={colors.primary} />
|
<Plus size={14} color={c.primary} />
|
||||||
{!!addLabel && <Text style={styles.addLabel}>{addLabel}</Text>}
|
{!!addLabel && <Text style={styles.addLabel}>{addLabel}</Text>}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -52,59 +57,63 @@ interface FieldRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FieldRow({ onRemove, children }: FieldRowProps) {
|
export function FieldRow({ onRemove, children }: FieldRowProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={{ flex: 1 }}>{children}</View>
|
<View style={{ flex: 1 }}>{children}</View>
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
|
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
|
||||||
<X size={14} color={colors.textMuted} />
|
<X size={14} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
block: {
|
return StyleSheet.create({
|
||||||
marginHorizontal: spacing.lg,
|
block: {
|
||||||
marginBottom: spacing.md,
|
marginHorizontal: spacing.lg,
|
||||||
paddingLeft: spacing.md,
|
marginBottom: spacing.md,
|
||||||
paddingRight: spacing.sm,
|
paddingLeft: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingRight: spacing.sm,
|
||||||
borderLeftWidth: 3,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderLeftWidth: 3,
|
||||||
backgroundColor: colors.card,
|
borderRadius: radius.sm,
|
||||||
},
|
backgroundColor: c.card,
|
||||||
header: {
|
},
|
||||||
flexDirection: 'row',
|
header: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row',
|
||||||
gap: spacing.xs,
|
alignItems: 'center',
|
||||||
marginBottom: spacing.sm,
|
gap: spacing.xs,
|
||||||
},
|
marginBottom: spacing.sm,
|
||||||
title: { ...typography.bodySemibold, color: colors.text },
|
},
|
||||||
addBtn: {
|
title: { ...typography.bodySemibold, color: c.text },
|
||||||
flexDirection: 'row',
|
addBtn: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row',
|
||||||
gap: 4,
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.sm,
|
gap: 4,
|
||||||
paddingVertical: 4,
|
paddingHorizontal: spacing.sm,
|
||||||
borderRadius: radius.full,
|
paddingVertical: 4,
|
||||||
backgroundColor: colors.primaryBg,
|
borderRadius: radius.full,
|
||||||
},
|
backgroundColor: c.primaryBg,
|
||||||
addLabel: { ...typography.caption, color: colors.primary },
|
},
|
||||||
body: { gap: spacing.sm },
|
addLabel: { ...typography.caption, color: c.primary },
|
||||||
row: {
|
body: { gap: spacing.sm },
|
||||||
flexDirection: 'row',
|
row: {
|
||||||
alignItems: 'flex-start',
|
flexDirection: 'row',
|
||||||
gap: spacing.sm,
|
alignItems: 'flex-start',
|
||||||
},
|
gap: spacing.sm,
|
||||||
removeBtn: {
|
},
|
||||||
width: 28,
|
removeBtn: {
|
||||||
height: 28,
|
width: 28,
|
||||||
alignItems: 'center',
|
height: 28,
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: radius.full,
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.surface,
|
borderRadius: radius.full,
|
||||||
marginTop: 4,
|
backgroundColor: c.surface,
|
||||||
},
|
marginTop: 4,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+19
-14
@@ -4,7 +4,8 @@ import Constants from 'expo-constants';
|
|||||||
import { ExternalLink } from 'lucide-react-native';
|
import { ExternalLink } from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
|
||||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||||
@@ -17,7 +18,9 @@ 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 c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
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);
|
||||||
@@ -64,7 +67,7 @@ export function AdvancedSettings() {
|
|||||||
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
|
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
|
||||||
>
|
>
|
||||||
<Text style={styles.ghText}>GitHub</Text>
|
<Text style={styles.ghText}>GitHub</Text>
|
||||||
<ExternalLink size={12} color={colors.mutedForeground} />
|
<ExternalLink size={12} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -122,14 +125,15 @@ export function AdvancedSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
aboutBox: {
|
aboutBox: {
|
||||||
padding: spacing.xl,
|
padding: spacing.xl,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
},
|
},
|
||||||
aboutRow: {
|
aboutRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -140,30 +144,31 @@ const styles = StyleSheet.create({
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
logoText: { fontSize: 24, fontWeight: '700', color: colors.primaryForeground },
|
logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground },
|
||||||
aboutTitle: { ...typography.bodyMedium, color: colors.text },
|
aboutTitle: { ...typography.bodyMedium, color: c.text },
|
||||||
aboutVersion: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||||
aboutCommit: { color: colors.mutedForeground, opacity: 0.6 },
|
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
|
||||||
ghLink: {
|
ghLink: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
ghText: { ...typography.caption, color: colors.mutedForeground },
|
ghText: { ...typography.caption, color: c.mutedForeground },
|
||||||
debugCategoriesBox: {
|
debugCategoriesBox: {
|
||||||
marginLeft: spacing.lg,
|
marginLeft: spacing.lg,
|
||||||
paddingLeft: spacing.lg,
|
paddingLeft: spacing.lg,
|
||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
borderLeftColor: colors.muted,
|
borderLeftColor: c.muted,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
debugCategoriesHint: {
|
debugCategoriesHint: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'luc
|
|||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import Input from '../Input';
|
import Input from '../Input';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +20,8 @@ const MOCK_SESSIONS: Session[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AccountSecuritySettings() {
|
export function AccountSecuritySettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [currentPwd, setCurrentPwd] = useState('');
|
const [currentPwd, setCurrentPwd] = useState('');
|
||||||
const [newPwd, setNewPwd] = useState('');
|
const [newPwd, setNewPwd] = useState('');
|
||||||
const [confirmPwd, setConfirmPwd] = useState('');
|
const [confirmPwd, setConfirmPwd] = useState('');
|
||||||
@@ -42,9 +45,9 @@ export function AccountSecuritySettings() {
|
|||||||
/>
|
/>
|
||||||
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
||||||
{showCurrent ? (
|
{showCurrent ? (
|
||||||
<EyeOff size={16} color={colors.mutedForeground} />
|
<EyeOff size={16} color={c.mutedForeground} />
|
||||||
) : (
|
) : (
|
||||||
<Eye size={16} color={colors.mutedForeground} />
|
<Eye size={16} color={c.mutedForeground} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -61,9 +64,9 @@ export function AccountSecuritySettings() {
|
|||||||
/>
|
/>
|
||||||
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
||||||
{showNew ? (
|
{showNew ? (
|
||||||
<EyeOff size={16} color={colors.mutedForeground} />
|
<EyeOff size={16} color={c.mutedForeground} />
|
||||||
) : (
|
) : (
|
||||||
<Eye size={16} color={colors.mutedForeground} />
|
<Eye size={16} color={c.mutedForeground} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -79,7 +82,7 @@ export function AccountSecuritySettings() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ alignItems: 'flex-end' }}>
|
<View style={{ alignItems: 'flex-end' }}>
|
||||||
<Button size="sm" icon={<Key size={14} color={colors.primaryForeground} />}>
|
<Button size="sm" icon={<Key size={14} color={c.primaryForeground} />}>
|
||||||
Change Password
|
Change Password
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -93,7 +96,7 @@ export function AccountSecuritySettings() {
|
|||||||
|
|
||||||
{twoFactor && (
|
{twoFactor && (
|
||||||
<View style={styles.infoBox}>
|
<View style={styles.infoBox}>
|
||||||
<Smartphone size={16} color={colors.primary} />
|
<Smartphone size={16} color={c.primary} />
|
||||||
<Text style={styles.infoText}>
|
<Text style={styles.infoText}>
|
||||||
Scan the QR code in your authenticator app to finish setup.
|
Scan the QR code in your authenticator app to finish setup.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -104,7 +107,7 @@ export function AccountSecuritySettings() {
|
|||||||
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
|
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
|
||||||
{appPwds.length === 0 ? (
|
{appPwds.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Lock size={24} color={colors.mutedForeground} />
|
<Lock size={24} color={c.mutedForeground} />
|
||||||
<Text style={styles.emptyText}>No app passwords yet.</Text>
|
<Text style={styles.emptyText}>No app passwords yet.</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -119,7 +122,7 @@ export function AccountSecuritySettings() {
|
|||||||
{MOCK_SESSIONS.map((s) => (
|
{MOCK_SESSIONS.map((s) => (
|
||||||
<View key={s.id} style={styles.sessionRow}>
|
<View key={s.id} style={styles.sessionRow}>
|
||||||
<View style={styles.sessionIcon}>
|
<View style={styles.sessionIcon}>
|
||||||
<Monitor size={18} color={colors.text} />
|
<Monitor size={18} color={c.text} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.sessionDevice}>{s.device}</Text>
|
<Text style={styles.sessionDevice}>{s.device}</Text>
|
||||||
@@ -129,7 +132,7 @@ export function AccountSecuritySettings() {
|
|||||||
</View>
|
</View>
|
||||||
{!s.current && (
|
{!s.current && (
|
||||||
<Pressable style={styles.trashBtn}>
|
<Pressable style={styles.trashBtn}>
|
||||||
<Trash2 size={16} color={colors.error} />
|
<Trash2 size={16} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -139,23 +142,24 @@ export function AccountSecuritySettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
pwField: { gap: 4 },
|
pwField: { gap: 4 },
|
||||||
pwLabel: { ...typography.caption, color: colors.mutedForeground },
|
pwLabel: { ...typography.caption, color: c.mutedForeground },
|
||||||
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
eyeBtn: { padding: spacing.sm },
|
eyeBtn: { padding: spacing.sm },
|
||||||
infoBox: {
|
infoBox: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
infoText: {
|
infoText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.primary,
|
color: c.primary,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
@@ -165,7 +169,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
},
|
},
|
||||||
sessionRow: {
|
sessionRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -173,18 +177,18 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
sessionIcon: {
|
sessionIcon: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
sessionDevice: { ...typography.bodyMedium, color: colors.text },
|
sessionDevice: { ...typography.bodyMedium, color: c.text },
|
||||||
sessionMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
sessionMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||||
trashBtn: {
|
trashBtn: {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -193,3 +197,4 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection, SettingItem } from './settings-section';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useAuthStore } from '../../stores/auth-store';
|
import { useAuthStore } from '../../stores/auth-store';
|
||||||
import { useAccountStore } from '../../stores/account-store';
|
import { useAccountStore } from '../../stores/account-store';
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ function formatFileSize(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings(props: AccountSettingsProps = {}) {
|
export function AccountSettings(props: AccountSettingsProps = {}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const authUsername = useAuthStore((s) => s.username);
|
const authUsername = useAuthStore((s) => s.username);
|
||||||
const authServerUrl = useAuthStore((s) => s.serverUrl);
|
const authServerUrl = useAuthStore((s) => s.serverUrl);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
@@ -99,10 +102,11 @@ export function AccountSettings(props: AccountSettingsProps = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
value: {
|
value: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
maxWidth: 240,
|
maxWidth: 240,
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
@@ -112,13 +116,13 @@ const styles = StyleSheet.create({
|
|||||||
bar: {
|
bar: {
|
||||||
width: 128,
|
width: 128,
|
||||||
height: 8,
|
height: 8,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
barFill: {
|
barFill: {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
demoRow: {
|
demoRow: {
|
||||||
@@ -137,3 +141,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fbbf24',
|
color: '#fbbf24',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import { PlayCircle } from 'lucide-react-native';
|
|
||||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = ThemeMode;
|
||||||
type FontSize = 'small' | 'medium' | 'large';
|
|
||||||
type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
|
||||||
type ToolbarPosition = 'top' | 'below-subject';
|
|
||||||
|
|
||||||
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
|
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
|
||||||
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
|
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
|
||||||
@@ -24,6 +21,8 @@ const PREVIEW_ROWS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function DensityPreview({ density }: { density: Density }) {
|
function DensityPreview({ density }: { density: Density }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const cfg = DENSITY_PREVIEW[density];
|
const cfg = DENSITY_PREVIEW[density];
|
||||||
return (
|
return (
|
||||||
<View style={styles.densityPreview}>
|
<View style={styles.densityPreview}>
|
||||||
@@ -50,7 +49,7 @@ function DensityPreview({ density }: { density: Density }) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.densitySender,
|
styles.densitySender,
|
||||||
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
|
row.unread ? { color: c.text, fontWeight: '600' } : { color: c.mutedForeground },
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -61,7 +60,7 @@ function DensityPreview({ density }: { density: Density }) {
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.densitySubject,
|
styles.densitySubject,
|
||||||
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
|
row.unread ? { color: c.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -80,16 +79,24 @@ function DensityPreview({ density }: { density: Density }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const [theme, setTheme] = useState<Theme>('dark');
|
const c = useColors();
|
||||||
const [fontSize, setFontSize] = useState<FontSize>('medium');
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [density, setDensity] = useState<Density>('regular');
|
const theme = useSettingsStore((s) => s.theme);
|
||||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition>('top');
|
const setTheme = useSettingsStore((s) => s.setTheme);
|
||||||
const [showToolbarLabels, setShowToolbarLabels] = useState(true);
|
const fontSize = useSettingsStore((s) => s.fontSize);
|
||||||
const [hideAccountSwitcher, setHideAccountSwitcher] = useState(false);
|
const setFontSize = useSettingsStore((s) => s.setFontSize);
|
||||||
const [showRailAccountList, setShowRailAccountList] = useState(false);
|
const density = useSettingsStore((s) => s.density);
|
||||||
const [colorfulSidebarIcons, setColorfulSidebarIcons] = useState(true);
|
const setDensity = useSettingsStore((s) => s.setDensity);
|
||||||
const [animationsEnabled, setAnimationsEnabled] = useState(true);
|
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
|
||||||
const [language, setLanguage] = useState('en');
|
const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels);
|
||||||
|
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
|
||||||
|
const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled);
|
||||||
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||||
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hydrated) void hydrate();
|
||||||
|
}, [hydrated, hydrate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
|
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
|
||||||
@@ -105,18 +112,6 @@ export function AppearanceSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label="Language" description="Interface language.">
|
|
||||||
<RadioGroup
|
|
||||||
value={language}
|
|
||||||
onChange={setLanguage}
|
|
||||||
options={[
|
|
||||||
{ value: 'en', label: 'EN' },
|
|
||||||
{ value: 'de', label: 'DE' },
|
|
||||||
{ value: 'fr', label: 'FR' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="Font Size" description="Adjust text size.">
|
<SettingItem label="Font Size" description="Adjust text size.">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
@@ -145,64 +140,36 @@ export function AppearanceSettings() {
|
|||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingItem label="Toolbar Position" description="Where to place the message toolbar.">
|
|
||||||
<RadioGroup
|
|
||||||
value={toolbarPosition}
|
|
||||||
onChange={(v) => setToolbarPosition(v as ToolbarPosition)}
|
|
||||||
options={[
|
|
||||||
{ value: 'top', label: 'Top' },
|
|
||||||
{ value: 'below-subject', label: 'Below' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
|
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
|
||||||
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
|
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label="Hide Account Switcher" description="Hide the account switcher from the navigation rail.">
|
|
||||||
<ToggleSwitch checked={hideAccountSwitcher} onChange={setHideAccountSwitcher} />
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="Rail Account List" description="Show all accounts inside the navigation rail.">
|
|
||||||
<ToggleSwitch checked={showRailAccountList} onChange={setShowRailAccountList} />
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="Colorful Sidebar Icons" description="Display sidebar icons with their brand colors.">
|
|
||||||
<ToggleSwitch checked={colorfulSidebarIcons} onChange={setColorfulSidebarIcons} />
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="Animations" description="Enable or disable interface animations.">
|
<SettingItem label="Animations" description="Enable or disable interface animations.">
|
||||||
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
|
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label="Restart Tour" description="Replay the onboarding walkthrough.">
|
|
||||||
<Button variant="outline" size="sm" icon={<PlayCircle size={14} color={colors.text} />}>
|
|
||||||
Restart
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
densityPreview: {
|
densityPreview: {
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
densityRow: {
|
densityRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
densityAvatar: {
|
densityAvatar: {
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
densityHeader: {
|
densityHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -216,7 +183,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
densityTime: {
|
densityTime: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
},
|
},
|
||||||
densitySubject: {
|
densitySubject: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
@@ -227,7 +194,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.border,
|
backgroundColor: c.border,
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||||
|
import { X, Plus } from 'lucide-react-native';
|
||||||
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
|
import Button from '../Button';
|
||||||
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
|
||||||
|
export function ComposingSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||||
|
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
|
||||||
|
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||||
|
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
|
||||||
|
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||||
|
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
||||||
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||||
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
|
|
||||||
|
const [newKeyword, setNewKeyword] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||||
|
|
||||||
|
const addKeyword = () => {
|
||||||
|
const trimmed = newKeyword.trim().toLowerCase();
|
||||||
|
if (!trimmed || attachmentReminderKeywords.includes(trimmed)) {
|
||||||
|
setNewKeyword('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAttachmentReminderKeywords([...attachmentReminderKeywords, trimmed]);
|
||||||
|
setNewKeyword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeKeyword = (kw: string) => {
|
||||||
|
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title="Composing"
|
||||||
|
description="How the composer behaves when you reply, forward, or send."
|
||||||
|
>
|
||||||
|
<SettingItem
|
||||||
|
label="Auto-select reply identity"
|
||||||
|
description="When replying, pick the identity that received the original message."
|
||||||
|
>
|
||||||
|
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label="Attachment reminder"
|
||||||
|
description="Warn before sending if the message mentions an attachment but none is attached."
|
||||||
|
>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={attachmentReminderEnabled}
|
||||||
|
onChange={setAttachmentReminderEnabled}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{attachmentReminderEnabled && (
|
||||||
|
<View style={styles.subBlock}>
|
||||||
|
<Text style={styles.subLabel}>Trigger keywords</Text>
|
||||||
|
<Text style={styles.subDescription}>
|
||||||
|
Words that, when found in the body or subject without an attachment, trigger the reminder.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{attachmentReminderKeywords.map((kw) => (
|
||||||
|
<View key={kw} style={styles.chip}>
|
||||||
|
<Text style={styles.chipText}>{kw}</Text>
|
||||||
|
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
|
||||||
|
<X size={12} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.addRow}>
|
||||||
|
<TextInput
|
||||||
|
value={newKeyword}
|
||||||
|
onChangeText={setNewKeyword}
|
||||||
|
placeholder="e.g. attached"
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
style={styles.input}
|
||||||
|
onSubmitEditing={addKeyword}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onPress={addKeyword}
|
||||||
|
disabled={!newKeyword.trim()}
|
||||||
|
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
|
||||||
|
subLabel: { ...typography.bodyMedium, color: c.text },
|
||||||
|
subDescription: { ...typography.caption, color: c.mutedForeground },
|
||||||
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: c.muted,
|
||||||
|
},
|
||||||
|
chipText: { ...typography.caption, color: c.text },
|
||||||
|
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 8,
|
||||||
|
color: c.text,
|
||||||
|
...typography.body,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ import { useSettingsStore } from '../../stores/settings-store';
|
|||||||
import { useContactsStore } from '../../stores/contacts-store';
|
import { useContactsStore } from '../../stores/contacts-store';
|
||||||
import { contactsToVCard } from '../../lib/vcard';
|
import { contactsToVCard } from '../../lib/vcard';
|
||||||
import { isGroup } from '../../lib/contact-utils';
|
import { isGroup } from '../../lib/contact-utils';
|
||||||
import { colors, typography } from '../../theme/tokens';
|
import { typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
export function ContactsSettings() {
|
export function ContactsSettings() {
|
||||||
|
const c = useColors();
|
||||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||||
const contacts = useContactsStore((s) => s.contacts);
|
const contacts = useContactsStore((s) => s.contacts);
|
||||||
@@ -67,7 +69,7 @@ export function ContactsSettings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<Download size={14} color={colors.text} />}
|
icon={<Download size={14} color={c.text} />}
|
||||||
onPress={() => { void handleExport(); }}
|
onPress={() => { void handleExport(); }}
|
||||||
disabled={exportable.length === 0 || exporting}
|
disabled={exportable.length === 0 || exporting}
|
||||||
loading={exporting}
|
loading={exporting}
|
||||||
@@ -80,7 +82,7 @@ export function ContactsSettings() {
|
|||||||
label="Import contacts"
|
label="Import contacts"
|
||||||
description="Importing vCard files is currently available on the web client only."
|
description="Importing vCard files is currently available on the web client only."
|
||||||
>
|
>
|
||||||
<Text style={[typography.caption, { color: colors.textMuted }]}>Web only</Text>
|
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Pressable, TextInput, FlatList, Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
import { ChevronRight, X, Plus, Trash2 } from 'lucide-react-native';
|
||||||
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
|
import Button from '../Button';
|
||||||
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||||
|
|
||||||
|
export function ContentSendersSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||||
|
const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||||
|
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||||
|
const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode);
|
||||||
|
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||||
|
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||||
|
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
|
||||||
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||||
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [newSender, setNewSender] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||||
|
|
||||||
|
const trustedCount = trustedSenders.length;
|
||||||
|
const trustedLabel = trustedCount === 0 ? 'None' : trustedCount === 1 ? '1 sender' : `${trustedCount} senders`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ gap: spacing.xxxl }}>
|
||||||
|
<SettingsSection
|
||||||
|
title="Content & Senders"
|
||||||
|
description="Control how external resources and tracking pixels are handled."
|
||||||
|
>
|
||||||
|
<SettingItem
|
||||||
|
label="External content"
|
||||||
|
description="What to do when an email links to remote images or media."
|
||||||
|
>
|
||||||
|
<RadioGroup
|
||||||
|
value={externalContentPolicy}
|
||||||
|
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
|
||||||
|
options={[
|
||||||
|
{ value: 'ask', label: 'Ask' },
|
||||||
|
{ value: 'block', label: 'Block' },
|
||||||
|
{ value: 'allow', label: 'Allow' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label="Always view emails in light mode"
|
||||||
|
description="Force a light background for the message body, even when the app is in dark mode."
|
||||||
|
>
|
||||||
|
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label="Trusted senders"
|
||||||
|
description="External content always loads for senders on this list."
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setModalOpen(true)}
|
||||||
|
style={({ pressed }) => [styles.trustedButton, pressed && styles.trustedButtonPressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.trustedButtonText}>{trustedLabel}</Text>
|
||||||
|
<ChevronRight size={14} color={c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={modalOpen}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||||
|
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Trusted senders</Text>
|
||||||
|
<Pressable onPress={() => setModalOpen(false)} hitSlop={6}>
|
||||||
|
<X size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.addRow}>
|
||||||
|
<TextInput
|
||||||
|
value={newSender}
|
||||||
|
onChangeText={setNewSender}
|
||||||
|
placeholder="sender@example.com"
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
style={styles.input}
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
if (newSender.trim()) {
|
||||||
|
addTrustedSender(newSender);
|
||||||
|
setNewSender('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => {
|
||||||
|
if (newSender.trim()) {
|
||||||
|
addTrustedSender(newSender);
|
||||||
|
setNewSender('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!newSender.trim()}
|
||||||
|
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{trustedSenders.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>No trusted senders yet.</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={trustedSenders}
|
||||||
|
keyExtractor={(item) => item}
|
||||||
|
style={styles.list}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.rowText} numberOfLines={1}>{item}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => removeTrustedSender(item)}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => [styles.removeBtn, pressed && styles.removeBtnPressed]}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} color={c.error} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
trustedButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: c.muted,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
trustedButtonPressed: { opacity: 0.7 },
|
||||||
|
trustedButtonText: { ...typography.body, color: c.text },
|
||||||
|
|
||||||
|
modalBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: c.card,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
padding: spacing.lg,
|
||||||
|
maxHeight: '80%',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modalTitle: { ...typography.h3, color: c.text },
|
||||||
|
addRow: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md },
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 8,
|
||||||
|
color: c.text,
|
||||||
|
...typography.body,
|
||||||
|
},
|
||||||
|
list: { maxHeight: 300 },
|
||||||
|
separator: { height: 1, backgroundColor: c.border },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
rowText: { ...typography.body, color: c.text, flex: 1, marginRight: spacing.sm },
|
||||||
|
removeBtn: { padding: 6, borderRadius: radius.sm },
|
||||||
|
removeBtnPressed: { backgroundColor: c.muted },
|
||||||
|
emptyText: {
|
||||||
|
...typography.body,
|
||||||
|
color: c.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: spacing.xl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
|
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
type FolderLayout = 'inline' | 'sidebar';
|
type FolderLayout = 'inline' | 'sidebar';
|
||||||
type ViewMode = 'list' | 'grid';
|
type ViewMode = 'list' | 'grid';
|
||||||
@@ -57,24 +58,26 @@ function formatSize(b: number) {
|
|||||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIcon(file: SampleFile, colored: boolean, sz: number) {
|
function getIcon(c: ThemePalette, file: SampleFile, colored: boolean, sz: number) {
|
||||||
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : colors.mutedForeground} />;
|
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : c.mutedForeground} />;
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'jpg': case 'png': case 'gif':
|
case 'jpg': case 'png': case 'gif':
|
||||||
return <ImageIcon size={sz} color={colored ? '#4ade80' : colors.mutedForeground} />;
|
return <ImageIcon size={sz} color={colored ? '#4ade80' : c.mutedForeground} />;
|
||||||
case 'mp3': case 'wav':
|
case 'mp3': case 'wav':
|
||||||
return <FileAudio size={sz} color={colored ? '#a78bfa' : colors.mutedForeground} />;
|
return <FileAudio size={sz} color={colored ? '#a78bfa' : c.mutedForeground} />;
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
return <FileText size={sz} color={colored ? '#f87171' : colors.mutedForeground} />;
|
return <FileText size={sz} color={colored ? '#f87171' : c.mutedForeground} />;
|
||||||
case 'md': case 'json': case 'js': case 'ts':
|
case 'md': case 'json': case 'js': case 'ts':
|
||||||
return <FileCode size={sz} color={colored ? '#fbbf24' : colors.mutedForeground} />;
|
return <FileCode size={sz} color={colored ? '#fbbf24' : c.mutedForeground} />;
|
||||||
default:
|
default:
|
||||||
return <File size={sz} color={colors.mutedForeground} />;
|
return <File size={sz} color={c.mutedForeground} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const files = SAMPLE.filter((f) => {
|
const files = SAMPLE.filter((f) => {
|
||||||
if (!prefs.showHiddenFiles && f.hidden) return false;
|
if (!prefs.showHiddenFiles && f.hidden) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -93,7 +96,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
|||||||
<View style={styles.gridWrap}>
|
<View style={styles.gridWrap}>
|
||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
|
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
|
||||||
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
|
{prefs.showIcons ? getIcon(c, f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
|
||||||
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
|
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -111,7 +114,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
|||||||
</View>
|
</View>
|
||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
|
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
|
||||||
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
|
{prefs.showIcons && getIcon(c, f, prefs.coloredIcons, 14)}
|
||||||
<Text
|
<Text
|
||||||
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
|
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -127,6 +130,8 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilesSettings() {
|
export function FilesSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
|
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
|
||||||
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
|
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
|
||||||
|
|
||||||
@@ -188,7 +193,7 @@ export function FilesSettings() {
|
|||||||
<SettingItem label="Show icons" description="Display icons for files and folders.">
|
<SettingItem label="Show icons" description="Display icons for files and folders.">
|
||||||
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
|
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
|
<SettingItem label="Colored icons" description="Use file-type-specific c.">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={prefs.coloredIcons}
|
checked={prefs.coloredIcons}
|
||||||
onChange={(v) => update({ coloredIcons: v })}
|
onChange={(v) => update({ coloredIcons: v })}
|
||||||
@@ -212,14 +217,15 @@ export function FilesSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
previewLabel: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
|
previewLabel: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
|
||||||
previewBox: {
|
previewBox: {
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
minHeight: 160,
|
minHeight: 160,
|
||||||
},
|
},
|
||||||
@@ -230,10 +236,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
listHeaderText: { fontSize: 10, fontWeight: '500', color: colors.mutedForeground },
|
listHeaderText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||||
listRow: {
|
listRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -241,10 +247,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
listName: { fontSize: 11, color: colors.text, flex: 1 },
|
listName: { fontSize: 11, color: c.text, flex: 1 },
|
||||||
listMeta: { fontSize: 10, color: colors.mutedForeground, width: 60, textAlign: 'right' },
|
listMeta: { fontSize: 10, color: c.mutedForeground, width: 60, textAlign: 'right' },
|
||||||
gridWrap: {
|
gridWrap: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@@ -258,5 +264,6 @@ const styles = StyleSheet.create({
|
|||||||
width: 72,
|
width: 72,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
gridName: { fontSize: 9, color: colors.text, textAlign: 'center' },
|
gridName: { fontSize: 9, color: c.text, textAlign: 'center' },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
|
|||||||
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
||||||
import { SettingsSection, ToggleSwitch } from './settings-section';
|
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface Rule {
|
interface Rule {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,8 @@ interface Rule {
|
|||||||
const SAMPLE_RULES: Rule[] = [];
|
const SAMPLE_RULES: Rule[] = [];
|
||||||
|
|
||||||
export function FilterSettings() {
|
export function FilterSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
||||||
const [expandedView, setExpandedView] = useState(false);
|
const [expandedView, setExpandedView] = useState(false);
|
||||||
const [isOpaque] = useState(false);
|
const [isOpaque] = useState(false);
|
||||||
@@ -45,7 +48,7 @@ export function FilterSettings() {
|
|||||||
<Text style={styles.linkText}>Open Sieve Editor</Text>
|
<Text style={styles.linkText}>Open Sieve Editor</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.resetLink}>
|
<Pressable style={styles.resetLink}>
|
||||||
<RotateCcw size={12} color={colors.error} />
|
<RotateCcw size={12} color={c.error} />
|
||||||
<Text style={styles.resetText}>Reset to visual</Text>
|
<Text style={styles.resetText}>Reset to visual</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -55,7 +58,7 @@ export function FilterSettings() {
|
|||||||
|
|
||||||
{rules.length === 0 && !isOpaque && (
|
{rules.length === 0 && !isOpaque && (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<Filter size={40} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
|
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||||
<Text style={styles.emptyText}>No rules yet.</Text>
|
<Text style={styles.emptyText}>No rules yet.</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -65,7 +68,7 @@ export function FilterSettings() {
|
|||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
||||||
<View style={styles.grip}>
|
<View style={styles.grip}>
|
||||||
<GripVertical size={16} color={colors.mutedForeground} />
|
<GripVertical size={16} color={c.mutedForeground} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
|
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
|
||||||
@@ -88,7 +91,7 @@ export function FilterSettings() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
|
||||||
<X size={16} color={colors.mutedForeground} />
|
<X size={16} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -100,11 +103,11 @@ export function FilterSettings() {
|
|||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
||||||
{!isOpaque && (
|
{!isOpaque && (
|
||||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
|
||||||
Add Rule
|
Add Rule
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" icon={<Code size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
|
||||||
Raw Editor
|
Raw Editor
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -120,7 +123,8 @@ export function FilterSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xl },
|
container: { gap: spacing.xl },
|
||||||
opaqueWarning: {
|
opaqueWarning: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -134,11 +138,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
opaqueText: { ...typography.caption, color: '#fde68a' },
|
opaqueText: { ...typography.caption, color: '#fde68a' },
|
||||||
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
|
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
|
||||||
linkText: { ...typography.caption, color: colors.primary, fontWeight: '500' },
|
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
|
||||||
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||||
resetText: { ...typography.caption, color: colors.error, fontWeight: '500' },
|
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
|
||||||
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
||||||
emptyText: { ...typography.body, color: colors.mutedForeground },
|
emptyText: { ...typography.body, color: c.mutedForeground },
|
||||||
ruleRow: {
|
ruleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
@@ -146,12 +150,12 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
ruleDisabled: { opacity: 0.6 },
|
ruleDisabled: { opacity: 0.6 },
|
||||||
grip: { paddingTop: 2 },
|
grip: { paddingTop: 2 },
|
||||||
ruleName: { ...typography.bodyMedium, color: colors.text },
|
ruleName: { ...typography.bodyMedium, color: c.text },
|
||||||
ruleSummary: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -167,5 +171,6 @@ const styles = StyleSheet.create({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
expandedLabel: { ...typography.caption, color: colors.mutedForeground },
|
expandedLabel: { ...typography.caption, color: c.mutedForeground },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection, SettingItem } from './settings-section';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useEmailStore } from '../../stores/email-store';
|
import { useEmailStore } from '../../stores/email-store';
|
||||||
import type { Mailbox } from '../../api/types';
|
import type { Mailbox } from '../../api/types';
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ function getIcon(mb: Mailbox) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FolderSettings() {
|
export function FolderSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||||
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||||
|
|
||||||
@@ -47,7 +50,7 @@ export function FolderSettings() {
|
|||||||
>
|
>
|
||||||
{mailboxes.length === 0 ? (
|
{mailboxes.length === 0 ? (
|
||||||
<View style={styles.loading}>
|
<View style={styles.loading}>
|
||||||
<ActivityIndicator size="small" color={colors.primary} />
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View>
|
<View>
|
||||||
@@ -58,7 +61,7 @@ export function FolderSettings() {
|
|||||||
<View style={styles.folderLeft}>
|
<View style={styles.folderLeft}>
|
||||||
<Icon
|
<Icon
|
||||||
size={16}
|
size={16}
|
||||||
color={mb.role ? colors.primary : colors.mutedForeground}
|
color={mb.role ? c.primary : c.mutedForeground}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
|
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
|
||||||
{mb.role && ROLE_LABEL[mb.role] && (
|
{mb.role && ROLE_LABEL[mb.role] && (
|
||||||
@@ -94,7 +97,8 @@ export function FolderSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
loading: { paddingVertical: 40, alignItems: 'center' },
|
loading: { paddingVertical: 40, alignItems: 'center' },
|
||||||
folderRow: {
|
folderRow: {
|
||||||
@@ -106,22 +110,23 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
|
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
|
||||||
folderName: { ...typography.body, color: colors.text, flexShrink: 1 },
|
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
|
||||||
rolePill: {
|
rolePill: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
rolePillText: { fontSize: 10, fontWeight: '500', color: colors.primary },
|
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||||
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
unreadBadge: {
|
unreadBadge: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
unreadText: { fontSize: 10, fontWeight: '500', color: colors.primaryForeground },
|
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
|
||||||
total: { ...typography.caption, color: colors.mutedForeground, minWidth: 32, textAlign: 'right' },
|
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
|
||||||
caption: { ...typography.caption, color: colors.mutedForeground },
|
caption: { ...typography.caption, color: c.mutedForeground },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import React, { useEffect } from 'react';
|
|||||||
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection, SettingItem } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, typography, spacing, radius } from '../../theme/tokens';
|
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
|
||||||
export function IdentitySettings() {
|
export function IdentitySettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const identities = useSettingsStore((s) => s.identities);
|
const identities = useSettingsStore((s) => s.identities);
|
||||||
const loading = useSettingsStore((s) => s.loading);
|
const loading = useSettingsStore((s) => s.loading);
|
||||||
const error = useSettingsStore((s) => s.error);
|
const error = useSettingsStore((s) => s.error);
|
||||||
@@ -30,7 +33,7 @@ export function IdentitySettings() {
|
|||||||
>
|
>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator size="small" color={colors.primary} />
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.count}>{countLabel}</Text>
|
<Text style={styles.count}>{countLabel}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -67,7 +70,8 @@ export function IdentitySettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -75,14 +79,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
count: {
|
count: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
errorBox: {
|
errorBox: {
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.errorBg,
|
backgroundColor: c.errorBg,
|
||||||
},
|
},
|
||||||
errorText: { ...typography.caption, color: colors.error },
|
errorText: { ...typography.caption, color: c.error },
|
||||||
identityRow: {
|
identityRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -90,15 +94,16 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
identityName: { ...typography.bodyMedium, color: colors.text },
|
identityName: { ...typography.bodyMedium, color: c.text },
|
||||||
identityEmail: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||||
badge: {
|
badge: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: 'rgba(59,130,246,0.15)',
|
backgroundColor: 'rgba(59,130,246,0.15)',
|
||||||
},
|
},
|
||||||
badgeText: { fontSize: 10, fontWeight: '500', color: colors.primary },
|
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,45 +1,47 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
|
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
||||||
|
import { DARK_COLORS } from '../../theme/tokens';
|
||||||
|
|
||||||
interface Keyword {
|
type Keyword = KeywordDef;
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
color: keyof typeof colors.tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
|
// Palette keys are theme-agnostic (same names in both palettes), so use DARK_COLORS
|
||||||
|
// at module load. The actual rendered swatch colors come from the active theme via `c.tags[key]`.
|
||||||
const DEFAULT_KEYWORDS: Keyword[] = [
|
const PALETTE_KEYS = Object.keys(DARK_COLORS.tags) as (keyof typeof DARK_COLORS.tags)[];
|
||||||
{ id: 'important', label: 'Important', color: 'red' },
|
|
||||||
{ id: 'work', label: 'Work', color: 'blue' },
|
|
||||||
{ id: 'personal', label: 'Personal', color: 'green' },
|
|
||||||
{ id: 'todo', label: 'Todo', color: 'amber' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function KeywordSettings() {
|
export function KeywordSettings() {
|
||||||
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const keywords = useKeywordsStore((s) => s.keywords);
|
||||||
|
const addKeyword = useKeywordsStore((s) => s.add);
|
||||||
|
const updateKeyword = useKeywordsStore((s) => s.update);
|
||||||
|
const removeKeyword = useKeywordsStore((s) => s.remove);
|
||||||
|
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
|
||||||
|
const hydrated = useKeywordsStore((s) => s.hydrated);
|
||||||
|
const hydrate = useKeywordsStore((s) => s.hydrate);
|
||||||
|
|
||||||
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
const saveKeyword = (kw: Keyword, editing: boolean) => {
|
const saveKeyword = (kw: Keyword, editing: boolean) => {
|
||||||
if (editing) {
|
if (editing && editingId) {
|
||||||
setKeywords((ks) => ks.map((k) => k.id === editingId ? kw : k));
|
const { id, ...patch } = kw;
|
||||||
|
updateKeyword(editingId, patch);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
} else {
|
} else {
|
||||||
setKeywords((ks) => [...ks, kw]);
|
addKeyword(kw);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteKeyword = (id: string) => {
|
const deleteKeyword = (id: string) => {
|
||||||
setKeywords((ks) => ks.filter((k) => k.id !== id));
|
removeKeyword(id);
|
||||||
};
|
|
||||||
|
|
||||||
const resetDefaults = () => {
|
|
||||||
setKeywords(DEFAULT_KEYWORDS);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +59,7 @@ export function KeywordSettings() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const palette = colors.tags[kw.color];
|
const palette = c.tags[kw.color];
|
||||||
return (
|
return (
|
||||||
<View key={kw.id} style={styles.kwRow}>
|
<View key={kw.id} style={styles.kwRow}>
|
||||||
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
||||||
@@ -65,10 +67,10 @@ export function KeywordSettings() {
|
|||||||
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
||||||
<View style={{ flexDirection: 'row', gap: 2 }}>
|
<View style={{ flexDirection: 'row', gap: 2 }}>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
|
||||||
<Pencil size={14} color={colors.mutedForeground} />
|
<Pencil size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
|
||||||
<Trash2 size={14} color={colors.mutedForeground} />
|
<Trash2 size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -86,11 +88,11 @@ export function KeywordSettings() {
|
|||||||
{!isAdding && editingId === null && (
|
{!isAdding && editingId === null && (
|
||||||
<View style={styles.bottomActions}>
|
<View style={styles.bottomActions}>
|
||||||
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
|
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
|
||||||
<Plus size={14} color={colors.mutedForeground} />
|
<Plus size={14} color={c.mutedForeground} />
|
||||||
<Text style={styles.outlineBtnText}>Add keyword</Text>
|
<Text style={styles.outlineBtnText}>Add keyword</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
|
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
|
||||||
<RotateCcw size={14} color={colors.mutedForeground} />
|
<RotateCcw size={14} color={c.mutedForeground} />
|
||||||
<Text style={styles.outlineBtnText}>Reset defaults</Text>
|
<Text style={styles.outlineBtnText}>Reset defaults</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -108,8 +110,10 @@ interface KeywordFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [label, setLabel] = useState(initial?.label ?? '');
|
const [label, setLabel] = useState(initial?.label ?? '');
|
||||||
const [color, setColor] = useState<keyof typeof colors.tags>(initial?.color ?? 'blue');
|
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
||||||
|
|
||||||
const normalizedId = label
|
const normalizedId = label
|
||||||
.trim()
|
.trim()
|
||||||
@@ -134,7 +138,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
value={label}
|
value={label}
|
||||||
onChangeText={setLabel}
|
onChangeText={setLabel}
|
||||||
placeholder="e.g. Important"
|
placeholder="e.g. Important"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -146,7 +150,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
<Text style={styles.formLabel}>Color</Text>
|
<Text style={styles.formLabel}>Color</Text>
|
||||||
<View style={styles.palette}>
|
<View style={styles.palette}>
|
||||||
{PALETTE_KEYS.map((key) => {
|
{PALETTE_KEYS.map((key) => {
|
||||||
const p = colors.tags[key];
|
const p = c.tags[key];
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={key}
|
key={key}
|
||||||
@@ -164,7 +168,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
|
|
||||||
<View style={styles.formActions}>
|
<View style={styles.formActions}>
|
||||||
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
|
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
|
||||||
<X size={14} color={colors.text} />
|
<X size={14} color={c.text} />
|
||||||
<Text style={styles.cancelFormText}>Cancel</Text>
|
<Text style={styles.cancelFormText}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -172,7 +176,7 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
>
|
>
|
||||||
<Check size={14} color={colors.primaryForeground} />
|
<Check size={14} color={c.primaryForeground} />
|
||||||
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
|
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -180,7 +184,8 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
kwRow: {
|
kwRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -188,12 +193,12 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
kwDot: { width: 20, height: 20, borderRadius: 10 },
|
kwDot: { width: 20, height: 20, borderRadius: 10 },
|
||||||
kwLabel: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||||
kwId: { fontSize: 10, color: colors.mutedForeground, fontFamily: 'monospace' },
|
kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' },
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -215,34 +220,34 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
outlineBtnText: { ...typography.caption, color: colors.mutedForeground },
|
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
|
||||||
form: {
|
form: {
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.primaryBorder,
|
borderColor: c.primaryBorder,
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: c.accent,
|
||||||
},
|
},
|
||||||
formLabel: { ...typography.caption, color: colors.mutedForeground, marginBottom: 4 },
|
formLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
|
||||||
input: {
|
input: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
errorText: { ...typography.caption, color: colors.error, marginTop: 4 },
|
errorText: { ...typography.caption, color: c.error, marginTop: 4 },
|
||||||
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||||
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
|
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
|
||||||
colorSwatchSelected: {
|
colorSwatchSelected: {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: colors.text,
|
borderColor: c.text,
|
||||||
},
|
},
|
||||||
formActions: {
|
formActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -257,9 +262,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
cancelFormText: { ...typography.caption, color: colors.text },
|
cancelFormText: { ...typography.caption, color: c.text },
|
||||||
saveBtn: {
|
saveBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -267,7 +272,8 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
saveBtnText: { ...typography.caption, color: colors.primaryForeground, fontWeight: '500' },
|
saveBtnText: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
|
import { Check } from 'lucide-react-native';
|
||||||
|
import { SettingsSection, SettingItem } from './settings-section';
|
||||||
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
|
||||||
|
|
||||||
|
export function LanguageSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const override = useLocaleStore((s) => s.override);
|
||||||
|
const setOverride = useLocaleStore((s) => s.setOverride);
|
||||||
|
const hydrated = useLocaleStore((s) => s.hydrated);
|
||||||
|
const hydrate = useLocaleStore((s) => s.hydrate);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
|
||||||
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||||
|
|
||||||
|
const deviceCode = detectDeviceLocale();
|
||||||
|
const selected: LocaleCode | 'system' = override ?? 'system';
|
||||||
|
|
||||||
|
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
|
||||||
|
const active = selected === code;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={code}
|
||||||
|
onPress={() => setOverride(code === 'system' ? null : (code as LocaleCode))}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
{sublabel ? <Text style={styles.sublabel}>{sublabel}</Text> : null}
|
||||||
|
</View>
|
||||||
|
{active ? <Check size={16} color={c.primary} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deviceLabel =
|
||||||
|
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.appearance.language.label', 'Language')}
|
||||||
|
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||||
|
>
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.appearance.language.label', 'Language')}
|
||||||
|
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||||
|
noBorder
|
||||||
|
/>
|
||||||
|
<View style={styles.list}>
|
||||||
|
{renderRow('system', `System default (${deviceLabel})`)}
|
||||||
|
<View style={styles.divider} />
|
||||||
|
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
list: { borderRadius: radius.md, overflow: 'hidden', backgroundColor: c.background },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: c.muted },
|
||||||
|
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
|
||||||
|
label: { ...typography.body, color: c.text },
|
||||||
|
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||||
|
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||||
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
|
||||||
|
|
||||||
|
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'archive', label: 'Archive' },
|
||||||
|
{ value: 'delete', label: 'Delete' },
|
||||||
|
{ value: 'spam', label: 'Spam' },
|
||||||
|
{ value: 'read', label: 'Read/Unread' },
|
||||||
|
{ value: 'star', label: 'Star' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LayoutSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||||
|
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
|
||||||
|
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||||
|
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
||||||
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||||
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
|
|
||||||
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||||
|
<View style={{ gap: spacing.sm }}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<ArrowRight size={14} color={c.mutedForeground} />
|
||||||
|
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowDescription}>
|
||||||
|
Action when you drag a row from its left edge towards the right.
|
||||||
|
</Text>
|
||||||
|
<RadioGroup
|
||||||
|
value={swipeRightAction}
|
||||||
|
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
|
||||||
|
options={SWIPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<ArrowLeft size={14} color={c.mutedForeground} />
|
||||||
|
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.rowDescription}>
|
||||||
|
Action when you drag a row from its right edge towards the left.
|
||||||
|
</Text>
|
||||||
|
<RadioGroup
|
||||||
|
value={swipeLeftAction}
|
||||||
|
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
|
||||||
|
options={SWIPE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SettingItem
|
||||||
|
label=""
|
||||||
|
description="Drag past about half a row to commit; lift earlier to cancel."
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
|
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||||
|
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native';
|
|||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import Dialog from '../Dialog';
|
import Dialog from '../Dialog';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import { colors, radius, spacing, typography } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useAuthStore } from '../../stores/auth-store';
|
import { useAuthStore } from '../../stores/auth-store';
|
||||||
import {
|
import {
|
||||||
DEFAULT_RELAY_BASE_URL,
|
DEFAULT_RELAY_BASE_URL,
|
||||||
@@ -35,6 +36,8 @@ type PushStatus =
|
|||||||
| { kind: 'error'; message: string };
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
export function NotificationSettings() {
|
export function NotificationSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const username = useAuthStore((s) => s.username);
|
const username = useAuthStore((s) => s.username);
|
||||||
|
|
||||||
const [sound, setSound] = useState('default');
|
const [sound, setSound] = useState('default');
|
||||||
@@ -120,26 +123,26 @@ export function NotificationSettings() {
|
|||||||
<View style={styles.statusRow}>
|
<View style={styles.statusRow}>
|
||||||
{pushStatus.kind === 'busy' && (
|
{pushStatus.kind === 'busy' && (
|
||||||
<>
|
<>
|
||||||
<ActivityIndicator size="small" color={colors.primary} />
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
<Text style={styles.statusText}>{pushStatus.message}</Text>
|
<Text style={styles.statusText}>{pushStatus.message}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pushStatus.kind === 'enabled' && (
|
{pushStatus.kind === 'enabled' && (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 size={14} color={colors.success} />
|
<CheckCircle2 size={14} color={c.success} />
|
||||||
<Text style={[styles.statusText, { color: colors.success }]}>Active</Text>
|
<Text style={[styles.statusText, { color: c.success }]}>Active</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pushStatus.kind === 'error' && (
|
{pushStatus.kind === 'error' && (
|
||||||
<>
|
<>
|
||||||
<XCircle size={14} color={colors.error} />
|
<XCircle size={14} color={c.error} />
|
||||||
<Text style={[styles.statusText, { color: colors.error }]}>
|
<Text style={[styles.statusText, { color: c.error }]}>
|
||||||
{pushStatus.message}
|
{pushStatus.message}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{pushStatus.kind === 'idle' && (
|
{pushStatus.kind === 'idle' && (
|
||||||
<Text style={[styles.statusText, { color: colors.mutedForeground }]}>
|
<Text style={[styles.statusText, { color: c.mutedForeground }]}>
|
||||||
Not configured
|
Not configured
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +155,7 @@ export function NotificationSettings() {
|
|||||||
value={relayUrl}
|
value={relayUrl}
|
||||||
onChangeText={setRelayUrl}
|
onChangeText={setRelayUrl}
|
||||||
placeholder={DEFAULT_RELAY_BASE_URL}
|
placeholder={DEFAULT_RELAY_BASE_URL}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -183,7 +186,7 @@ export function NotificationSettings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
icon={<Volume2 size={16} color={colors.text} />}
|
icon={<Volume2 size={16} color={c.text} />}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={sound}
|
value={sound}
|
||||||
@@ -229,12 +232,13 @@ export function NotificationSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
pushCard: {
|
pushCard: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
@@ -247,18 +251,18 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
fieldLabel: {
|
fieldLabel: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
fieldDescription: {
|
fieldDescription: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
},
|
},
|
||||||
urlInput: {
|
urlInput: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -266,6 +270,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
|
||||||
statusText: { ...typography.caption, color: colors.text },
|
statusText: { ...typography.caption, color: c.text },
|
||||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
|
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
|
|||||||
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
|
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface Plugin {
|
interface Plugin {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,15 +20,19 @@ interface Plugin {
|
|||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
|
function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; fg: string } {
|
||||||
installed: { bg: colors.muted, fg: colors.mutedForeground },
|
switch (status) {
|
||||||
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
|
case 'installed': return { bg: c.muted, fg: c.mutedForeground };
|
||||||
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
|
case 'enabled': return { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' };
|
||||||
disabled: { bg: colors.muted, fg: colors.mutedForeground },
|
case 'running': return { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' };
|
||||||
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
|
case 'disabled': return { bg: c.muted, fg: c.mutedForeground };
|
||||||
};
|
case 'error': return { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -52,7 +57,7 @@ export function PluginsSettings() {
|
|||||||
>
|
>
|
||||||
{plugins.length === 0 ? (
|
{plugins.length === 0 ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Puzzle size={48} color={colors.mutedForeground} style={{ opacity: 0.3 }} />
|
<Puzzle size={48} color={c.mutedForeground} style={{ opacity: 0.3 }} />
|
||||||
<Text style={styles.emptyTitle}>No plugins installed</Text>
|
<Text style={styles.emptyTitle}>No plugins installed</Text>
|
||||||
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
|
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -60,13 +65,13 @@ export function PluginsSettings() {
|
|||||||
<View style={{ gap: spacing.sm }}>
|
<View style={{ gap: spacing.sm }}>
|
||||||
{plugins.map((plugin) => {
|
{plugins.map((plugin) => {
|
||||||
const expanded = expandedId === plugin.id;
|
const expanded = expandedId === plugin.id;
|
||||||
const status = STATUS_STYLE[plugin.status];
|
const status = statusStyle(c, plugin.status);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
style={[
|
style={[
|
||||||
styles.card,
|
styles.card,
|
||||||
plugin.status === 'error' && { borderColor: colors.errorBorder },
|
plugin.status === 'error' && { borderColor: c.errorBorder },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
@@ -107,7 +112,7 @@ export function PluginsSettings() {
|
|||||||
|
|
||||||
{plugin.error && (
|
{plugin.error && (
|
||||||
<View style={styles.errorBox}>
|
<View style={styles.errorBox}>
|
||||||
<AlertTriangle size={14} color={colors.error} />
|
<AlertTriangle size={14} color={c.error} />
|
||||||
<Text style={styles.errorText}>{plugin.error}</Text>
|
<Text style={styles.errorText}>{plugin.error}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +150,7 @@ export function PluginsSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
|
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
|
||||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||||
Upload .zip
|
Upload .zip
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
@@ -153,18 +158,19 @@ export function PluginsSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
empty: {
|
empty: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: spacing.xxxl,
|
paddingVertical: spacing.xxxl,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
emptyTitle: { ...typography.body, color: colors.mutedForeground },
|
emptyTitle: { ...typography.body, color: c.mutedForeground },
|
||||||
emptyDesc: { ...typography.caption, color: colors.mutedForeground },
|
emptyDesc: { ...typography.caption, color: c.mutedForeground },
|
||||||
card: {
|
card: {
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
cardHeader: {
|
cardHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -173,7 +179,7 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
},
|
},
|
||||||
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||||
pluginName: { ...typography.bodyMedium, color: colors.text },
|
pluginName: { ...typography.bodyMedium, color: c.text },
|
||||||
statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full },
|
statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full },
|
||||||
statusText: { fontSize: 10, fontWeight: '500' },
|
statusText: { fontSize: 10, fontWeight: '500' },
|
||||||
forcedPill: {
|
forcedPill: {
|
||||||
@@ -187,38 +193,39 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' },
|
forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' },
|
||||||
metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 },
|
metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 },
|
||||||
meta: { ...typography.caption, color: colors.mutedForeground },
|
meta: { ...typography.caption, color: c.mutedForeground },
|
||||||
metaDot: { ...typography.caption, color: colors.mutedForeground, opacity: 0.6 },
|
metaDot: { ...typography.caption, color: c.mutedForeground, opacity: 0.6 },
|
||||||
cardBody: {
|
cardBody: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
description: { ...typography.caption, color: colors.mutedForeground },
|
description: { ...typography.caption, color: c.mutedForeground },
|
||||||
errorBox: {
|
errorBox: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
padding: spacing.sm,
|
padding: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.errorBg,
|
backgroundColor: c.errorBg,
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
errorText: { ...typography.caption, color: colors.error, flex: 1 },
|
errorText: { ...typography.caption, color: c.error, flex: 1 },
|
||||||
sectionLabel: { ...typography.captionMedium, color: colors.text, marginBottom: 4 },
|
sectionLabel: { ...typography.captionMedium, color: c.text, marginBottom: 4 },
|
||||||
permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
|
||||||
permPill: {
|
permPill: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
permText: { fontSize: 10, color: colors.mutedForeground },
|
permText: { fontSize: 10, color: c.mutedForeground },
|
||||||
uninstallRow: {
|
uninstallRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.sm,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+44
-88
@@ -2,7 +2,8 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||||
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
|
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||||
|
|
||||||
type MailLayout = 'split' | 'focus';
|
type MailLayout = 'split' | 'focus';
|
||||||
@@ -14,6 +15,8 @@ type AttachmentPosition = 'beside-sender' | 'below-header';
|
|||||||
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
|
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
|
||||||
|
|
||||||
function MailLayoutPreview({ value }: { value: MailLayout }) {
|
function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const isSplit = value === 'split';
|
const isSplit = value === 'split';
|
||||||
return (
|
return (
|
||||||
<View style={styles.layoutPreview}>
|
<View style={styles.layoutPreview}>
|
||||||
@@ -65,7 +68,9 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailSettings() {
|
export function ReadingSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
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);
|
||||||
@@ -124,7 +129,7 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
{deleteAction === 'permanent' && (
|
{deleteAction === 'permanent' && (
|
||||||
<View style={styles.warning}>
|
<View style={styles.warning}>
|
||||||
<AlertTriangle size={14} color={colors.error} />
|
<AlertTriangle size={14} color={c.error} />
|
||||||
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
|
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -144,7 +149,7 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
{archiveMode !== 'single' && (
|
{archiveMode !== 'single' && (
|
||||||
<Pressable style={styles.inlineBtn}>
|
<Pressable style={styles.inlineBtn}>
|
||||||
<FolderSync size={14} color={colors.text} />
|
<FolderSync size={14} color={c.text} />
|
||||||
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
|
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -206,7 +211,7 @@ export function EmailSettings() {
|
|||||||
}
|
}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<X size={12} color={colors.mutedForeground} />
|
<X size={12} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -216,7 +221,7 @@ export function EmailSettings() {
|
|||||||
value={newKeyword}
|
value={newKeyword}
|
||||||
onChangeText={setNewKeyword}
|
onChangeText={setNewKeyword}
|
||||||
placeholder="Add a keyword"
|
placeholder="Add a keyword"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.keywordInput}
|
style={styles.keywordInput}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
const t = newKeyword.trim().toLowerCase();
|
const t = newKeyword.trim().toLowerCase();
|
||||||
@@ -282,62 +287,12 @@ export function EmailSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem label="Always Light Mode for Emails" description="Render email content on a white background.">
|
|
||||||
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<SettingItem label="External Content" description="How to handle images and remote resources in incoming email.">
|
|
||||||
<Select
|
|
||||||
value={externalContentPolicy}
|
|
||||||
onChange={(v) => setExternalContentPolicyStore(v as ExternalContentPolicy)}
|
|
||||||
options={[
|
|
||||||
{ value: 'ask', label: 'Ask' },
|
|
||||||
{ value: 'block', label: 'Block' },
|
|
||||||
{ value: 'allow', label: 'Allow' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
|
|
||||||
<View style={styles.group}>
|
|
||||||
<SettingItem
|
|
||||||
label="Trusted Senders"
|
|
||||||
description="Senders whose external content loads without asking."
|
|
||||||
noBorder
|
|
||||||
>
|
|
||||||
<Pressable style={styles.inlineBtn} onPress={() => setShowTrustedList((v) => !v)}>
|
|
||||||
<Text style={styles.inlineBtnText}>
|
|
||||||
{trustedSenders.length === 0
|
|
||||||
? 'No trusted senders'
|
|
||||||
: `${trustedSenders.length} sender${trustedSenders.length === 1 ? '' : 's'}`}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</SettingItem>
|
|
||||||
{showTrustedList && trustedSenders.length > 0 && (
|
|
||||||
<View style={styles.trustedList}>
|
|
||||||
{trustedSenders.map((email) => (
|
|
||||||
<View key={email} style={styles.trustedRow}>
|
|
||||||
<Text style={styles.trustedEmail} numberOfLines={1}>{email}</Text>
|
|
||||||
<Pressable onPress={() => removeTrustedSender(email)} hitSlop={8}>
|
|
||||||
<X size={14} color={colors.mutedForeground} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={styles.divider} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<SettingItem label="Sync Trusted Senders" description="Store trusted senders in the address book.">
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={trustedSendersAddressBook}
|
|
||||||
onChange={setTrustedSendersAddressBook}
|
|
||||||
/>
|
|
||||||
</SettingItem>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
group: {},
|
group: {},
|
||||||
warning: {
|
warning: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -349,12 +304,12 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
warningText: {
|
warningText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.error,
|
color: c.error,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.border,
|
backgroundColor: c.border,
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
inlineBtn: {
|
inlineBtn: {
|
||||||
@@ -363,23 +318,23 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
inlineBtnText: {
|
inlineBtnText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
subLabel: {
|
subLabel: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
subDesc: {
|
subDesc: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
@@ -395,11 +350,11 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
addKeywordRow: {
|
addKeywordRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -411,37 +366,37 @@ const styles = StyleSheet.create({
|
|||||||
height: 32,
|
height: 32,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
addKeywordBtn: {
|
addKeywordBtn: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
addKeywordText: {
|
addKeywordText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
layoutPreview: {
|
layoutPreview: {
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
},
|
},
|
||||||
layoutTitle: {
|
layoutTitle: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
layoutDesc: {
|
layoutDesc: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
layoutFrame: {
|
layoutFrame: {
|
||||||
@@ -450,27 +405,27 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'rgba(24,24,27,0.4)',
|
backgroundColor: 'rgba(24,24,27,0.4)',
|
||||||
},
|
},
|
||||||
layoutRail: {
|
layoutRail: {
|
||||||
width: 32,
|
width: 32,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: colors.border,
|
borderRightColor: c.border,
|
||||||
backgroundColor: 'rgba(24,24,27,0.6)',
|
backgroundColor: 'rgba(24,24,27,0.6)',
|
||||||
},
|
},
|
||||||
layoutList: {
|
layoutList: {
|
||||||
width: 96,
|
width: 96,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: colors.border,
|
borderRightColor: c.border,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
layoutRow: {
|
layoutRow: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
layoutRowSel: {
|
layoutRowSel: {
|
||||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||||
@@ -478,16 +433,16 @@ const styles = StyleSheet.create({
|
|||||||
layoutRowName: {
|
layoutRowName: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
layoutRowSub: {
|
layoutRowSub: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
},
|
},
|
||||||
layoutReader: {
|
layoutReader: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
layoutBar: {
|
layoutBar: {
|
||||||
height: 6,
|
height: 6,
|
||||||
@@ -506,14 +461,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
layoutFocusText: {
|
layoutFocusText: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
trustedList: {
|
trustedList: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
trustedRow: {
|
trustedRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -523,7 +478,8 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
trustedEmail: { ...typography.caption, color: colors.text, flex: 1 },
|
trustedEmail: { ...typography.caption, color: c.text, flex: 1 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface SidebarApp {
|
interface SidebarApp {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +18,8 @@ interface SidebarApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarAppsSettings() {
|
export function SidebarAppsSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [keepLoaded, setKeepLoaded] = useState(false);
|
const [keepLoaded, setKeepLoaded] = useState(false);
|
||||||
const [apps, setApps] = useState<SidebarApp[]>([]);
|
const [apps, setApps] = useState<SidebarApp[]>([]);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -63,9 +66,9 @@ export function SidebarAppsSettings() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View key={app.id} style={styles.appRow}>
|
<View key={app.id} style={styles.appRow}>
|
||||||
<GripVertical size={16} color={colors.mutedForeground} style={{ opacity: 0.5 }} />
|
<GripVertical size={16} color={c.mutedForeground} style={{ opacity: 0.5 }} />
|
||||||
<View style={styles.appIcon}>
|
<View style={styles.appIcon}>
|
||||||
<Globe size={16} color={colors.text} />
|
<Globe size={16} color={c.text} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
|
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
|
||||||
@@ -77,16 +80,16 @@ export function SidebarAppsSettings() {
|
|||||||
]}>
|
]}>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.modeBadgeText,
|
styles.modeBadgeText,
|
||||||
{ color: app.openMode === 'inline' ? '#60a5fa' : colors.mutedForeground },
|
{ color: app.openMode === 'inline' ? '#60a5fa' : c.mutedForeground },
|
||||||
]}>
|
]}>
|
||||||
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
|
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
|
||||||
<Pencil size={14} color={colors.mutedForeground} />
|
<Pencil size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
|
||||||
<Trash2 size={14} color={colors.mutedForeground} />
|
<Trash2 size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -101,7 +104,7 @@ export function SidebarAppsSettings() {
|
|||||||
|
|
||||||
{!isAdding && editingId === null && (
|
{!isAdding && editingId === null && (
|
||||||
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
|
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
|
||||||
<Plus size={16} color={colors.text} />
|
<Plus size={16} color={c.text} />
|
||||||
<Text style={styles.addBtnText}>Add app</Text>
|
<Text style={styles.addBtnText}>Add app</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -118,6 +121,8 @@ interface AppFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
const [url, setUrl] = useState(initial?.url ?? '');
|
const [url, setUrl] = useState(initial?.url ?? '');
|
||||||
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
|
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
|
||||||
@@ -133,7 +138,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
placeholder="App name"
|
placeholder="App name"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={formStyles.input}
|
style={formStyles.input}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -144,7 +149,7 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
value={url}
|
value={url}
|
||||||
onChangeText={setUrl}
|
onChangeText={setUrl}
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={formStyles.input}
|
style={formStyles.input}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -158,8 +163,8 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
onPress={() => setOpenMode('tab')}
|
onPress={() => setOpenMode('tab')}
|
||||||
style={[formStyles.modeBtn, openMode === 'tab' && formStyles.modeBtnActive]}
|
style={[formStyles.modeBtn, openMode === 'tab' && formStyles.modeBtnActive]}
|
||||||
>
|
>
|
||||||
<ExternalLink size={14} color={openMode === 'tab' ? colors.primary : colors.text} />
|
<ExternalLink size={14} color={openMode === 'tab' ? c.primary : c.text} />
|
||||||
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: colors.primary }]}>
|
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: c.primary }]}>
|
||||||
New tab
|
New tab
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -167,8 +172,8 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
onPress={() => setOpenMode('inline')}
|
onPress={() => setOpenMode('inline')}
|
||||||
style={[formStyles.modeBtn, openMode === 'inline' && formStyles.modeBtnActive]}
|
style={[formStyles.modeBtn, openMode === 'inline' && formStyles.modeBtnActive]}
|
||||||
>
|
>
|
||||||
<PanelRight size={14} color={openMode === 'inline' ? colors.primary : colors.text} />
|
<PanelRight size={14} color={openMode === 'inline' ? c.primary : c.text} />
|
||||||
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: colors.primary }]}>
|
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: c.primary }]}>
|
||||||
Inline
|
Inline
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -194,11 +199,12 @@ function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
emptyText: {
|
emptyText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingVertical: spacing.lg,
|
paddingVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
@@ -209,25 +215,25 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
appIcon: {
|
appIcon: {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
appName: { ...typography.bodyMedium, color: colors.text },
|
appName: { ...typography.bodyMedium, color: c.text },
|
||||||
appUrl: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
appUrl: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||||
modeBadge: {
|
modeBadge: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
|
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
|
||||||
modeBadgeTab: { backgroundColor: colors.muted },
|
modeBadgeTab: { backgroundColor: c.muted },
|
||||||
modeBadgeText: { fontSize: 10, fontWeight: '500' },
|
modeBadgeText: { fontSize: 10, fontWeight: '500' },
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -244,45 +250,48 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
addBtnText: { ...typography.body, color: colors.text },
|
addBtnText: { ...typography.body, color: c.text },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const formStyles = StyleSheet.create({
|
function makeFormStyles(c: ThemePalette) {
|
||||||
form: {
|
return StyleSheet.create({
|
||||||
gap: spacing.md,
|
form: {
|
||||||
padding: spacing.lg,
|
gap: spacing.md,
|
||||||
borderRadius: radius.md,
|
padding: spacing.lg,
|
||||||
borderWidth: 1,
|
borderRadius: radius.md,
|
||||||
borderColor: colors.border,
|
borderWidth: 1,
|
||||||
backgroundColor: colors.muted,
|
borderColor: c.border,
|
||||||
},
|
backgroundColor: c.muted,
|
||||||
label: { ...typography.captionMedium, color: colors.text },
|
},
|
||||||
input: {
|
label: { ...typography.captionMedium, color: c.text },
|
||||||
marginTop: 4,
|
input: {
|
||||||
paddingHorizontal: spacing.md,
|
marginTop: 4,
|
||||||
paddingVertical: 8,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: radius.sm,
|
paddingVertical: 8,
|
||||||
backgroundColor: colors.background,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
backgroundColor: c.background,
|
||||||
borderColor: colors.border,
|
borderWidth: 1,
|
||||||
color: colors.text,
|
borderColor: c.border,
|
||||||
...typography.body,
|
color: c.text,
|
||||||
},
|
...typography.body,
|
||||||
modeBtn: {
|
},
|
||||||
flexDirection: 'row',
|
modeBtn: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row',
|
||||||
gap: 6,
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.md,
|
gap: 6,
|
||||||
paddingVertical: 8,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: radius.sm,
|
paddingVertical: 8,
|
||||||
borderWidth: 1,
|
borderRadius: radius.sm,
|
||||||
borderColor: colors.border,
|
borderWidth: 1,
|
||||||
},
|
borderColor: c.border,
|
||||||
modeBtnActive: {
|
},
|
||||||
borderColor: colors.primary,
|
modeBtnActive: {
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
borderColor: c.primary,
|
||||||
},
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
modeBtnText: { ...typography.caption, color: colors.text },
|
},
|
||||||
});
|
modeBtnText: { ...typography.caption, color: c.text },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface KeyRecord {
|
interface KeyRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,6 +32,8 @@ const MOCK_KEYS: KeyRecord[] = [];
|
|||||||
const MOCK_CERTS: PublicCert[] = [];
|
const MOCK_CERTS: PublicCert[] = [];
|
||||||
|
|
||||||
export function SmimeSettings() {
|
export function SmimeSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
|
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
|
||||||
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
|
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
|
||||||
const [bindings, setBindings] = useState<Record<string, string>>({});
|
const [bindings, setBindings] = useState<Record<string, string>>({});
|
||||||
@@ -64,9 +67,9 @@ export function SmimeSettings() {
|
|||||||
<View style={styles.certLeft}>
|
<View style={styles.certLeft}>
|
||||||
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
|
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
|
||||||
{expired ? (
|
{expired ? (
|
||||||
<ShieldAlert size={16} color={colors.error} />
|
<ShieldAlert size={16} color={c.error} />
|
||||||
) : (
|
) : (
|
||||||
<ShieldCheck size={16} color={colors.primary} />
|
<ShieldCheck size={16} color={c.primary} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -80,19 +83,19 @@ export function SmimeSettings() {
|
|||||||
<View style={styles.certActions}>
|
<View style={styles.certActions}>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
|
||||||
{k.unlocked ? (
|
{k.unlocked ? (
|
||||||
<Unlock size={16} color={colors.success} />
|
<Unlock size={16} color={c.success} />
|
||||||
) : (
|
) : (
|
||||||
<Lock size={16} color={colors.text} />
|
<Lock size={16} color={c.text} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn}>
|
<Pressable style={styles.iconBtn}>
|
||||||
<Eye size={16} color={colors.text} />
|
<Eye size={16} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn}>
|
<Pressable style={styles.iconBtn}>
|
||||||
<Download size={16} color={colors.text} />
|
<Download size={16} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
|
||||||
<Trash2 size={16} color={colors.error} />
|
<Trash2 size={16} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -105,7 +108,7 @@ export function SmimeSettings() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
||||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||||
Import PKCS#12
|
Import PKCS#12
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -116,28 +119,28 @@ export function SmimeSettings() {
|
|||||||
description="Public certificates used to encrypt outgoing mail."
|
description="Public certificates used to encrypt outgoing mail."
|
||||||
>
|
>
|
||||||
<View style={{ gap: spacing.sm }}>
|
<View style={{ gap: spacing.sm }}>
|
||||||
{certs.map((c) => {
|
{certs.map((cert) => {
|
||||||
const expired = isExpired(c.notAfter);
|
const expired = isExpired(cert.notAfter);
|
||||||
return (
|
return (
|
||||||
<View key={c.id} style={styles.certRow}>
|
<View key={cert.id} style={styles.certRow}>
|
||||||
<View style={styles.certLeft}>
|
<View style={styles.certLeft}>
|
||||||
<View style={[styles.certIcon, styles.certIconMuted]}>
|
<View style={[styles.certIcon, styles.certIconMuted]}>
|
||||||
<Users size={16} color={colors.mutedForeground} />
|
<Users size={16} color={c.mutedForeground} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
|
<Text style={styles.certName} numberOfLines={1}>{cert.email || cert.subject}</Text>
|
||||||
<Text style={styles.certMeta}>
|
<Text style={styles.certMeta}>
|
||||||
{c.issuer} · {c.source}
|
{cert.issuer} · {cert.source}
|
||||||
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
|
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.certActions}>
|
<View style={styles.certActions}>
|
||||||
<Pressable style={styles.iconBtn}>
|
<Pressable style={styles.iconBtn}>
|
||||||
<Eye size={16} color={colors.text} />
|
<Eye size={16} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
|
<Pressable style={styles.iconBtn} onPress={() => deleteCert(cert.id)}>
|
||||||
<Trash2 size={16} color={colors.error} />
|
<Trash2 size={16} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -150,7 +153,7 @@ export function SmimeSettings() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
|
||||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||||
Import Certificate
|
Import Certificate
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -185,7 +188,8 @@ export function SmimeSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
certRow: {
|
certRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -195,7 +199,7 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
certLeft: {
|
certLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -211,12 +215,12 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
certIconOk: { backgroundColor: colors.primaryBg },
|
certIconOk: { backgroundColor: c.primaryBg },
|
||||||
certIconError: { backgroundColor: colors.errorBg },
|
certIconError: { backgroundColor: c.errorBg },
|
||||||
certIconMuted: { backgroundColor: colors.muted },
|
certIconMuted: { backgroundColor: c.muted },
|
||||||
certName: { ...typography.bodyMedium, color: colors.text },
|
certName: { ...typography.bodyMedium, color: c.text },
|
||||||
certMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
|
certMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||||
expiredText: { color: colors.error },
|
expiredText: { color: c.error },
|
||||||
certActions: { flexDirection: 'row', gap: 2 },
|
certActions: { flexDirection: 'row', gap: 2 },
|
||||||
iconBtn: {
|
iconBtn: {
|
||||||
width: 32,
|
width: 32,
|
||||||
@@ -227,8 +231,9 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingVertical: spacing.lg,
|
paddingVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { View, Text, StyleSheet } from 'react-native';
|
|||||||
import { FileText, Download, Upload } from 'lucide-react-native';
|
import { FileText, Download, Upload } from 'lucide-react-native';
|
||||||
import { SettingsSection } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, typography } from '../../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
export function TemplateSettings() {
|
export function TemplateSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [templates] = useState<{ id: string; name: string }[]>([]);
|
const [templates] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +16,7 @@ export function TemplateSettings() {
|
|||||||
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
|
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={styles.countRow}>
|
<View style={styles.countRow}>
|
||||||
<FileText size={16} color={colors.mutedForeground} />
|
<FileText size={16} color={c.mutedForeground} />
|
||||||
<Text style={styles.count}>
|
<Text style={styles.count}>
|
||||||
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
|
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -28,11 +31,11 @@ export function TemplateSettings() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={templates.length === 0}
|
disabled={templates.length === 0}
|
||||||
icon={<Download size={14} color={colors.text} />}
|
icon={<Download size={14} color={c.text} />}
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -41,7 +44,8 @@ export function TemplateSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -50,6 +54,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
},
|
},
|
||||||
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
count: { ...typography.body, color: colors.mutedForeground },
|
count: { ...typography.body, color: c.mutedForeground },
|
||||||
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
|
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
|
|||||||
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
|
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection, SettingItem } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
|
||||||
interface Theme {
|
interface Theme {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -20,6 +21,8 @@ const BUILT_IN: Theme[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function ThemesSettings() {
|
export function ThemesSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [themes] = useState<Theme[]>(BUILT_IN);
|
const [themes] = useState<Theme[]>(BUILT_IN);
|
||||||
const [active, setActive] = useState<string | null>(null);
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -40,12 +43,12 @@ export function ThemesSettings() {
|
|||||||
style={[styles.card, isActive && styles.cardActive]}
|
style={[styles.card, isActive && styles.cardActive]}
|
||||||
>
|
>
|
||||||
<View style={styles.preview}>
|
<View style={styles.preview}>
|
||||||
<Palette size={32} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
|
<Palette size={32} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ width: '100%' }}>
|
<View style={{ width: '100%' }}>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<Text style={styles.cardName} numberOfLines={1}>{theme.name}</Text>
|
<Text style={styles.cardName} numberOfLines={1}>{theme.name}</Text>
|
||||||
{isActive && <Check size={16} color={colors.primary} />}
|
{isActive && <Check size={16} color={c.primary} />}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.cardAuthor} numberOfLines={1}>{theme.author}</Text>
|
<Text style={styles.cardAuthor} numberOfLines={1}>{theme.author}</Text>
|
||||||
{theme.variants && (
|
{theme.variants && (
|
||||||
@@ -67,7 +70,7 @@ export function ThemesSettings() {
|
|||||||
label="Upload Theme"
|
label="Upload Theme"
|
||||||
description="Install a custom theme from a .zip file."
|
description="Install a custom theme from a .zip file."
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
|
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
|
||||||
Upload .zip
|
Upload .zip
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
@@ -75,7 +78,8 @@ export function ThemesSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
grid: {
|
grid: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@@ -87,19 +91,19 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
cardActive: {
|
cardActive: {
|
||||||
borderColor: colors.primary,
|
borderColor: c.primary,
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.05)',
|
backgroundColor: 'rgba(59, 130, 246, 0.05)',
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: 16 / 10,
|
aspectRatio: 16 / 10,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -108,14 +112,15 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
cardName: { ...typography.bodyMedium, color: colors.text, flex: 1 },
|
cardName: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||||
cardAuthor: { ...typography.caption, color: colors.mutedForeground },
|
cardAuthor: { ...typography.caption, color: c.mutedForeground },
|
||||||
variants: { flexDirection: 'row', gap: 4, marginTop: 4 },
|
variants: { flexDirection: 'row', gap: 4, marginTop: 4 },
|
||||||
variantPill: {
|
variantPill: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
variantText: { fontSize: 10, color: colors.mutedForeground },
|
variantText: { fontSize: 10, color: c.mutedForeground },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
|
import { View, Text, StyleSheet, Linking } from 'react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
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 +13,38 @@ 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,
|
||||||
|
): string {
|
||||||
|
if (!installing) return 'Install';
|
||||||
|
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 c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
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);
|
||||||
@@ -32,14 +61,17 @@ export function UpdatesSettings() {
|
|||||||
|
|
||||||
const updateAvailable = hasUpdate();
|
const updateAvailable = hasUpdate();
|
||||||
const apkAsset = cachedLatest?.apkAsset ?? null;
|
const apkAsset = cachedLatest?.apkAsset ?? null;
|
||||||
|
const apkPending = updateAvailable && !apkAsset;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apkPending) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
void checkNow({ force: true });
|
||||||
|
}, 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [apkPending, checkNow]);
|
||||||
|
|
||||||
const onInstall = () => {
|
const onInstall = () => {
|
||||||
if (!apkAsset) {
|
|
||||||
if (cachedLatest?.htmlUrl) {
|
|
||||||
void Linking.openURL(cachedLatest.htmlUrl);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void installLatest();
|
void installLatest();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,19 +101,38 @@ export function UpdatesSettings() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={updateAvailable ? 'Update available' : 'Check for updates'}
|
label={
|
||||||
description={updateAvailable
|
apkPending
|
||||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
? 'Update building'
|
||||||
: undefined}
|
: updateAvailable
|
||||||
|
? 'Update available'
|
||||||
|
: 'Check for updates'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
apkPending
|
||||||
|
? `v${cachedLatest?.tag} was published, but the APK is still being built. This page will check again every 30 seconds.`
|
||||||
|
: updateAvailable
|
||||||
|
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{updateAvailable ? (
|
{apkPending ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => void checkNow({ force: true })}
|
||||||
|
disabled={checking}
|
||||||
|
>
|
||||||
|
{checking ? 'Checking…' : 'Check again'}
|
||||||
|
</Button>
|
||||||
|
) : updateAvailable ? (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={onInstall}
|
onPress={onInstall}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
>
|
>
|
||||||
{installing ? 'Downloading…' : apkAsset ? 'Install' : 'Open release'}
|
{installButtonLabel(installing, installProgress)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -95,6 +146,44 @@ export function UpdatesSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{apkPending && cachedLatest?.htmlUrl ? (
|
||||||
|
<SettingItem
|
||||||
|
label="Release page"
|
||||||
|
description="Watch the build progress or download the APK manually."
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
|
||||||
|
>
|
||||||
|
Open on GitHub
|
||||||
|
</Button>
|
||||||
|
</SettingItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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>
|
||||||
@@ -108,17 +197,35 @@ export function UpdatesSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xxxl },
|
container: { gap: spacing.xxxl },
|
||||||
value: { ...typography.bodyMedium, color: colors.text },
|
value: { ...typography.bodyMedium, color: c.text },
|
||||||
notesBox: {
|
notesBox: {
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' },
|
notesTitle: { ...typography.caption, color: c.mutedForeground, textTransform: 'uppercase' },
|
||||||
notesBody: { ...typography.body, color: colors.text, lineHeight: 20 },
|
notesBody: { ...typography.body, color: c.text, lineHeight: 20 },
|
||||||
|
progressBox: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
progressTrack: {
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: c.muted,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: c.primary,
|
||||||
|
},
|
||||||
|
progressLabel: { ...typography.caption, color: c.mutedForeground },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert
|
|||||||
import { AlertTriangle, Eye, EyeOff } from 'lucide-react-native';
|
import { AlertTriangle, Eye, EyeOff } from 'lucide-react-native';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import { useVacationStore } from '../../stores/vacation-store';
|
import { useVacationStore } from '../../stores/vacation-store';
|
||||||
|
|
||||||
function toJmapDate(input: string): string | null {
|
function toJmapDate(input: string): string | null {
|
||||||
@@ -23,6 +24,8 @@ function fromJmapDate(value: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VacationSettings() {
|
export function VacationSettings() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const store = useVacationStore();
|
const store = useVacationStore();
|
||||||
|
|
||||||
const [enabled, setEnabled] = useState(store.isEnabled);
|
const [enabled, setEnabled] = useState(store.isEnabled);
|
||||||
@@ -75,7 +78,7 @@ export function VacationSettings() {
|
|||||||
if (store.isLoading && !store.hasLoaded) {
|
if (store.isLoading && !store.hasLoaded) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<ActivityIndicator color={colors.primary} />
|
<ActivityIndicator color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,7 @@ export function VacationSettings() {
|
|||||||
return (
|
return (
|
||||||
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
|
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
|
||||||
<View style={styles.unsupported}>
|
<View style={styles.unsupported}>
|
||||||
<AlertTriangle size={16} color={colors.warning} />
|
<AlertTriangle size={16} color={c.warning} />
|
||||||
<Text style={styles.unsupportedText}>
|
<Text style={styles.unsupportedText}>
|
||||||
Your server does not advertise support for the JMAP vacation responder.
|
Your server does not advertise support for the JMAP vacation responder.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -114,7 +117,7 @@ export function VacationSettings() {
|
|||||||
value={fromDate}
|
value={fromDate}
|
||||||
onChangeText={setFromDate}
|
onChangeText={setFromDate}
|
||||||
placeholder="YYYY-MM-DD HH:MM"
|
placeholder="YYYY-MM-DD HH:MM"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.dateInput}
|
style={styles.dateInput}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
@@ -124,7 +127,7 @@ export function VacationSettings() {
|
|||||||
value={toDate}
|
value={toDate}
|
||||||
onChangeText={setToDate}
|
onChangeText={setToDate}
|
||||||
placeholder="YYYY-MM-DD HH:MM"
|
placeholder="YYYY-MM-DD HH:MM"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.dateInput}
|
style={styles.dateInput}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
@@ -137,7 +140,7 @@ export function VacationSettings() {
|
|||||||
value={subject}
|
value={subject}
|
||||||
onChangeText={setSubject}
|
onChangeText={setSubject}
|
||||||
placeholder="Out of office"
|
placeholder="Out of office"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.subjectInput}
|
style={styles.subjectInput}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
@@ -148,7 +151,7 @@ export function VacationSettings() {
|
|||||||
value={body}
|
value={body}
|
||||||
onChangeText={setBody}
|
onChangeText={setBody}
|
||||||
placeholder="I am away from…"
|
placeholder="I am away from…"
|
||||||
placeholderTextColor={colors.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.bodyInput}
|
style={styles.bodyInput}
|
||||||
multiline
|
multiline
|
||||||
numberOfLines={6}
|
numberOfLines={6}
|
||||||
@@ -161,9 +164,9 @@ export function VacationSettings() {
|
|||||||
<SettingsSection title="Preview">
|
<SettingsSection title="Preview">
|
||||||
<Pressable style={styles.previewToggle} onPress={() => setShowPreview((v) => !v)}>
|
<Pressable style={styles.previewToggle} onPress={() => setShowPreview((v) => !v)}>
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<EyeOff size={14} color={colors.primary} />
|
<EyeOff size={14} color={c.primary} />
|
||||||
) : (
|
) : (
|
||||||
<Eye size={14} color={colors.primary} />
|
<Eye size={14} color={c.primary} />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.previewToggleText}>
|
<Text style={styles.previewToggleText}>
|
||||||
{showPreview ? 'Hide preview' : 'Show preview'}
|
{showPreview ? 'Hide preview' : 'Show preview'}
|
||||||
@@ -182,7 +185,7 @@ export function VacationSettings() {
|
|||||||
<View style={styles.warnings}>
|
<View style={styles.warnings}>
|
||||||
{warnings.map((w, i) => (
|
{warnings.map((w, i) => (
|
||||||
<View key={i} style={styles.warnRow}>
|
<View key={i} style={styles.warnRow}>
|
||||||
<AlertTriangle size={14} color={colors.warning} />
|
<AlertTriangle size={14} color={c.warning} />
|
||||||
<Text style={styles.warnText}>{w}</Text>
|
<Text style={styles.warnText}>{w}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -204,7 +207,8 @@ export function VacationSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: { gap: spacing.xl },
|
container: { gap: spacing.xl },
|
||||||
centered: { paddingVertical: 40, alignItems: 'center' },
|
centered: { paddingVertical: 40, alignItems: 'center' },
|
||||||
unsupported: {
|
unsupported: {
|
||||||
@@ -215,7 +219,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: 'rgba(202,138,4,0.12)',
|
backgroundColor: 'rgba(202,138,4,0.12)',
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
unsupportedText: { ...typography.body, color: colors.warning, flex: 1 },
|
unsupportedText: { ...typography.body, color: c.warning, flex: 1 },
|
||||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||||
pill: {
|
pill: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
@@ -223,19 +227,19 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
pillActive: { backgroundColor: 'rgba(22,163,74,0.2)' },
|
pillActive: { backgroundColor: 'rgba(22,163,74,0.2)' },
|
||||||
pillInactive: { backgroundColor: colors.muted },
|
pillInactive: { backgroundColor: c.muted },
|
||||||
pillText: { fontSize: 11, fontWeight: '500' },
|
pillText: { fontSize: 11, fontWeight: '500' },
|
||||||
pillTextActive: { color: '#4ade80' },
|
pillTextActive: { color: '#4ade80' },
|
||||||
pillTextInactive: { color: colors.mutedForeground },
|
pillTextInactive: { color: c.mutedForeground },
|
||||||
dateInput: {
|
dateInput: {
|
||||||
minWidth: 180,
|
minWidth: 180,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
subjectInput: {
|
subjectInput: {
|
||||||
@@ -243,47 +247,48 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
bodyBlock: { paddingVertical: spacing.md },
|
bodyBlock: { paddingVertical: spacing.md },
|
||||||
bodyLabel: { ...typography.bodyMedium, color: colors.text },
|
bodyLabel: { ...typography.bodyMedium, color: c.text },
|
||||||
bodyDesc: { ...typography.caption, color: colors.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
|
bodyDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
|
||||||
bodyInput: {
|
bodyInput: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: 96,
|
minHeight: 96,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
previewToggle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
previewToggle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
previewToggleText: { ...typography.body, color: colors.primary },
|
previewToggleText: { ...typography.body, color: c.primary },
|
||||||
previewBox: {
|
previewBox: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
previewSubject: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
|
previewSubject: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
|
||||||
previewBody: { ...typography.body, color: colors.mutedForeground },
|
previewBody: { ...typography.body, color: c.mutedForeground },
|
||||||
warnings: { gap: spacing.sm },
|
warnings: { gap: spacing.sm },
|
||||||
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
warnText: { ...typography.body, color: colors.warning, flex: 1 },
|
warnText: { ...typography.body, color: c.warning, flex: 1 },
|
||||||
errorBox: {
|
errorBox: {
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.errorBg,
|
backgroundColor: c.errorBg,
|
||||||
},
|
},
|
||||||
errorText: { ...typography.body, color: colors.error },
|
errorText: { ...typography.body, color: c.error },
|
||||||
saveRow: { alignItems: 'flex-end' },
|
saveRow: { alignItems: 'flex-end' },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
|
import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
import ToggleSwitchComponent from '../ToggleSwitch';
|
import ToggleSwitchComponent from '../ToggleSwitch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,8 @@ export function SettingsSection({
|
|||||||
experimental,
|
experimental,
|
||||||
experimentalDescription,
|
experimentalDescription,
|
||||||
}: SettingsSectionProps) {
|
}: SettingsSectionProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
{experimental && (
|
{experimental && (
|
||||||
@@ -73,12 +76,14 @@ export function SettingItem({
|
|||||||
locked,
|
locked,
|
||||||
noBorder,
|
noBorder,
|
||||||
}: SettingItemProps) {
|
}: SettingItemProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
|
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
|
||||||
<View style={styles.settingContent}>
|
<View style={styles.settingContent}>
|
||||||
<View style={styles.settingLabelRow}>
|
<View style={styles.settingLabelRow}>
|
||||||
<Text style={styles.settingLabel}>{label}</Text>
|
<Text style={styles.settingLabel}>{label}</Text>
|
||||||
{locked && <Lock size={12} color={colors.mutedForeground} />}
|
{locked && <Lock size={12} color={c.mutedForeground} />}
|
||||||
</View>
|
</View>
|
||||||
{description && <Text style={styles.settingDescription}>{description}</Text>}
|
{description && <Text style={styles.settingDescription}>{description}</Text>}
|
||||||
</View>
|
</View>
|
||||||
@@ -111,6 +116,8 @@ interface RadioGroupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) {
|
export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.radioGroup, style]}>
|
<View style={[styles.radioGroup, style]}>
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
@@ -145,6 +152,8 @@ interface SelectProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ value, onChange, options, style }: SelectProps) {
|
export function Select({ value, onChange, options, style }: SelectProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const current = options.find((o) => o.value === value);
|
const current = options.find((o) => o.value === value);
|
||||||
|
|
||||||
@@ -152,7 +161,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
<>
|
<>
|
||||||
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
|
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
|
||||||
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
||||||
<ChevronDown size={14} color={colors.mutedForeground} />
|
<ChevronDown size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Modal
|
<Modal
|
||||||
visible={open}
|
visible={open}
|
||||||
@@ -178,7 +187,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.selectItemText}>{opt.label}</Text>
|
<Text style={styles.selectItemText}>{opt.label}</Text>
|
||||||
{selected && <Check size={16} color={colors.primary} />}
|
{selected && <Check size={16} color={c.primary} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -190,7 +199,8 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
section: {
|
section: {
|
||||||
gap: spacing.lg,
|
gap: spacing.lg,
|
||||||
},
|
},
|
||||||
@@ -199,11 +209,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
...typography.h3,
|
...typography.h3,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
sectionDescription: {
|
sectionDescription: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
experimentalBanner: {
|
experimentalBanner: {
|
||||||
@@ -233,7 +243,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
settingItemBorder: {
|
settingItemBorder: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
locked: {
|
locked: {
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
@@ -249,11 +259,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
settingLabel: {
|
settingLabel: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
settingDescription: {
|
settingDescription: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.mutedForeground,
|
color: c.mutedForeground,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
settingRight: {
|
settingRight: {
|
||||||
@@ -270,20 +280,20 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
radioSelected: {
|
radioSelected: {
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
radioUnselected: {
|
radioUnselected: {
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
},
|
},
|
||||||
radioText: {
|
radioText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
},
|
},
|
||||||
radioTextSelected: {
|
radioTextSelected: {
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
radioTextUnselected: {
|
radioTextUnselected: {
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -292,14 +302,14 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
selectText: {
|
selectText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
@@ -313,10 +323,10 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 320,
|
maxWidth: 320,
|
||||||
maxHeight: '70%',
|
maxHeight: '70%',
|
||||||
backgroundColor: colors.popover,
|
backgroundColor: c.popover,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
selectItem: {
|
selectItem: {
|
||||||
@@ -327,10 +337,11 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
},
|
},
|
||||||
selectItemPressed: {
|
selectItemPressed: {
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: c.accent,
|
||||||
},
|
},
|
||||||
selectItemText: {
|
selectItemText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Minimal i18n: lookup keys like `settings.tabs.account` against a flat
|
||||||
|
// dictionary loaded from ./locales/<lang>/common.json. Picks language from
|
||||||
|
// the user override (locale-store) → device locale → English fallback.
|
||||||
|
import { getLocales } from 'expo-localization';
|
||||||
|
import en from '../../locales/en/common.json';
|
||||||
|
import de from '../../locales/de/common.json';
|
||||||
|
import cs from '../../locales/cs/common.json';
|
||||||
|
import es from '../../locales/es/common.json';
|
||||||
|
import fr from '../../locales/fr/common.json';
|
||||||
|
import it from '../../locales/it/common.json';
|
||||||
|
import ja from '../../locales/ja/common.json';
|
||||||
|
import ko from '../../locales/ko/common.json';
|
||||||
|
import lv from '../../locales/lv/common.json';
|
||||||
|
import nl from '../../locales/nl/common.json';
|
||||||
|
import pl from '../../locales/pl/common.json';
|
||||||
|
import pt from '../../locales/pt/common.json';
|
||||||
|
import ru from '../../locales/ru/common.json';
|
||||||
|
import uk from '../../locales/uk/common.json';
|
||||||
|
import zh from '../../locales/zh/common.json';
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = [
|
||||||
|
{ code: 'en', label: 'English' },
|
||||||
|
{ code: 'cs', label: 'Čeština' },
|
||||||
|
{ code: 'de', label: 'Deutsch' },
|
||||||
|
{ code: 'es', label: 'Español' },
|
||||||
|
{ code: 'fr', label: 'Français' },
|
||||||
|
{ code: 'it', label: 'Italiano' },
|
||||||
|
{ code: 'nl', label: 'Nederlands' },
|
||||||
|
{ code: 'pl', label: 'Polski' },
|
||||||
|
{ code: 'pt', label: 'Português' },
|
||||||
|
{ code: 'ru', label: 'Русский' },
|
||||||
|
{ code: 'uk', label: 'Українська' },
|
||||||
|
{ code: 'ja', label: '日本語' },
|
||||||
|
{ code: 'ko', label: '한국어' },
|
||||||
|
{ code: 'zh', label: '中文' },
|
||||||
|
{ code: 'lv', label: 'Latviešu' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LocaleCode = typeof SUPPORTED_LOCALES[number]['code'];
|
||||||
|
|
||||||
|
const dictionaries: Record<LocaleCode, Record<string, unknown>> = {
|
||||||
|
en, cs, de, es, fr, it, nl, pl, pt, ru, uk, ja, ko, zh, lv,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function detectDeviceLocale(): LocaleCode {
|
||||||
|
const codes = SUPPORTED_LOCALES.map((l) => l.code) as readonly string[];
|
||||||
|
for (const l of getLocales()) {
|
||||||
|
const lang = l.languageCode?.toLowerCase();
|
||||||
|
if (lang && codes.includes(lang)) return lang as LocaleCode;
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
let current: unknown = dict;
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
|
||||||
|
current = (current as Record<string, unknown>)[part];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof current === 'string' ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translate(locale: LocaleCode, key: string, fallback?: string): string {
|
||||||
|
return lookup(dictionaries[locale], key)
|
||||||
|
?? lookup(dictionaries.en, key)
|
||||||
|
?? fallback
|
||||||
|
?? key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
markdownToHtml,
|
||||||
|
wrapSelection,
|
||||||
|
toggleLinePrefix,
|
||||||
|
formatReplyQuote,
|
||||||
|
} from '../compose-format';
|
||||||
|
|
||||||
|
describe('markdownToHtml', () => {
|
||||||
|
it('escapes HTML in plain paragraphs', () => {
|
||||||
|
expect(markdownToHtml('hi <script>alert(1)</script>')).toBe(
|
||||||
|
'<p>hi <script>alert(1)</script></p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bold and italic', () => {
|
||||||
|
expect(markdownToHtml('see **this** and _that_')).toContain('<b>this</b>');
|
||||||
|
expect(markdownToHtml('see **this** and _that_')).toContain('<i>that</i>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bullet list', () => {
|
||||||
|
const html = markdownToHtml('- one\n- two\n- three');
|
||||||
|
expect(html).toBe('<ul><li>one</li><li>two</li><li>three</li></ul>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ordered list', () => {
|
||||||
|
const html = markdownToHtml('1. one\n2. two');
|
||||||
|
expect(html).toBe('<ol><li>one</li><li>two</li></ol>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders blockquotes', () => {
|
||||||
|
expect(markdownToHtml('> quoted\n> more')).toBe(
|
||||||
|
'<blockquote>quoted<br />more</blockquote>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves multiple paragraphs separated by blank lines', () => {
|
||||||
|
const html = markdownToHtml('first line\n\nsecond para');
|
||||||
|
expect(html).toBe('<p>first line</p>\n<p>second para</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves single line breaks within a paragraph as <br>', () => {
|
||||||
|
const html = markdownToHtml('one\ntwo');
|
||||||
|
expect(html).toBe('<p>one<br />two</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown links', () => {
|
||||||
|
const html = markdownToHtml('Click [here](https://example.com) please');
|
||||||
|
expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer">here</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects javascript: links', () => {
|
||||||
|
const html = markdownToHtml('[evil](javascript:alert(1))');
|
||||||
|
expect(html).not.toContain('href="javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-links bare URLs', () => {
|
||||||
|
const html = markdownToHtml('go to https://x.test now');
|
||||||
|
expect(html).toContain('<a href="https://x.test"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders inline cid images', () => {
|
||||||
|
const html = markdownToHtml('');
|
||||||
|
expect(html).toContain('<img src="cid:abc@x" alt="pic.png" />');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wrapSelection', () => {
|
||||||
|
it('wraps the selected range with markers', () => {
|
||||||
|
const r = wrapSelection('hello world', { start: 6, end: 11 }, '**', '**');
|
||||||
|
expect(r.text).toBe('hello **world**');
|
||||||
|
expect(r.selection).toEqual({ start: 8, end: 13 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts placeholder when nothing is selected', () => {
|
||||||
|
const r = wrapSelection('hi ', { start: 3, end: 3 }, '**', '**', 'bold');
|
||||||
|
expect(r.text).toBe('hi **bold**');
|
||||||
|
expect(r.selection).toEqual({ start: 5, end: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles reversed selection (end before start)', () => {
|
||||||
|
const r = wrapSelection('abc', { start: 3, end: 0 }, '_', '_');
|
||||||
|
expect(r.text).toBe('_abc_');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleLinePrefix', () => {
|
||||||
|
it('adds prefix to a single line', () => {
|
||||||
|
const r = toggleLinePrefix('hello', { start: 2, end: 2 }, '- ');
|
||||||
|
expect(r.text).toBe('- hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds prefix to every line in selection', () => {
|
||||||
|
const r = toggleLinePrefix('one\ntwo\nthree', { start: 0, end: 13 }, '- ');
|
||||||
|
expect(r.text).toBe('- one\n- two\n- three');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes prefix when every line already has it (toggle)', () => {
|
||||||
|
const r = toggleLinePrefix('- one\n- two', { start: 0, end: 11 }, '- ');
|
||||||
|
expect(r.text).toBe('one\ntwo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatReplyQuote', () => {
|
||||||
|
it('prefixes original body lines with "> "', () => {
|
||||||
|
const out = formatReplyQuote('hello\nworld', {
|
||||||
|
senderName: 'Alice',
|
||||||
|
date: new Date('2026-04-27'),
|
||||||
|
});
|
||||||
|
expect(out).toContain('> hello');
|
||||||
|
expect(out).toContain('> world');
|
||||||
|
expect(out).toContain('Alice wrote:');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,9 +117,29 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
|
|||||||
safety++;
|
safety++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sort each day's bucket: all-day events first, then timed events by start.
|
||||||
|
// Without this, events from different calendars come out in arrival order
|
||||||
|
// (roughly grouped by calendar) rather than chronologically.
|
||||||
|
for (const arr of idx.values()) {
|
||||||
|
arr.sort(compareEventsForDay);
|
||||||
|
}
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareEventsForDay(a: CalendarEvent, b: CalendarEvent): number {
|
||||||
|
const aAllDay = !!a.showWithoutTime;
|
||||||
|
const bAllDay = !!b.showWithoutTime;
|
||||||
|
if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
|
||||||
|
const aStart = getEventStartDate(a).getTime();
|
||||||
|
const bStart = getEventStartDate(b).getTime();
|
||||||
|
if (aStart !== bStart) return aStart - bStart;
|
||||||
|
// Tie-breaker on end (shorter events first), then title for stability.
|
||||||
|
const aEnd = getEventEndDate(a).getTime();
|
||||||
|
const bEnd = getEventEndDate(b).getTime();
|
||||||
|
if (aEnd !== bEnd) return aEnd - bEnd;
|
||||||
|
return (a.title ?? '').localeCompare(b.title ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
export function eventsOnDayFromIndex(
|
export function eventsOnDayFromIndex(
|
||||||
index: EventDayIndex,
|
index: EventDayIndex,
|
||||||
day: Date,
|
day: Date,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Lightweight markdown-style formatting for the native composer.
|
||||||
|
//
|
||||||
|
// The compose body is plain text the user types into a TextInput. The toolbar
|
||||||
|
// inserts markdown-like markers around the current selection (** for bold,
|
||||||
|
// _ for italic, "- " line prefix for lists). On send we render that text to
|
||||||
|
// HTML for `htmlBody` and keep the source as `textBody`.
|
||||||
|
//
|
||||||
|
// This is intentionally NOT a full markdown parser - it covers the formats
|
||||||
|
// the toolbar produces plus a few common conveniences (auto-link bare URLs,
|
||||||
|
// preserve line breaks, blockquote `> `).
|
||||||
|
|
||||||
|
import { escapeHtml } from './email-html';
|
||||||
|
|
||||||
|
const BOLD_RE = /\*\*([^*\n]+)\*\*/g;
|
||||||
|
const ITALIC_RE = /(^|[^*\w])_([^_\n]+)_(?=$|[^*\w])/g;
|
||||||
|
const CID_IMG_RE = /!\[([^\]]*)\]\(cid:([^)\s]+)\)/g;
|
||||||
|
// Combined matcher: either a markdown link `[label](url)` or a bare URL.
|
||||||
|
// Handling both in one pass means URLs inside a markdown link's parentheses
|
||||||
|
// don't get auto-linked a second time.
|
||||||
|
const LINK_OR_URL_RE =
|
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+)\)|(\bhttps?:\/\/[^\s<>()"']+)/gi;
|
||||||
|
|
||||||
|
function inlineFormat(text: string): string {
|
||||||
|
let out = escapeHtml(text);
|
||||||
|
out = out.replace(CID_IMG_RE, (_m, alt: string, cid: string) =>
|
||||||
|
`<img src="cid:${cid}" alt="${alt}" />`,
|
||||||
|
);
|
||||||
|
out = out.replace(LINK_OR_URL_RE, (full, label, mdUrl, bareUrl) => {
|
||||||
|
if (label !== undefined && mdUrl) {
|
||||||
|
return `<a href="${mdUrl}" rel="noopener noreferrer">${label}</a>`;
|
||||||
|
}
|
||||||
|
if (bareUrl) {
|
||||||
|
return `<a href="${bareUrl}" rel="noopener noreferrer">${bareUrl}</a>`;
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
});
|
||||||
|
out = out.replace(BOLD_RE, '<b>$1</b>');
|
||||||
|
out = out.replace(ITALIC_RE, '$1<i>$2</i>');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: 'p'; lines: string[] }
|
||||||
|
| { kind: 'ul'; items: string[] }
|
||||||
|
| { kind: 'ol'; items: string[] }
|
||||||
|
| { kind: 'quote'; lines: string[] };
|
||||||
|
|
||||||
|
export function markdownToHtml(source: string): string {
|
||||||
|
if (!source) return '';
|
||||||
|
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
let para: string[] = [];
|
||||||
|
|
||||||
|
const flushPara = () => {
|
||||||
|
if (para.length) {
|
||||||
|
blocks.push({ kind: 'p', lines: para });
|
||||||
|
para = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed === '') {
|
||||||
|
flushPara();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ulMatch = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||||
|
if (ulMatch) {
|
||||||
|
flushPara();
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (last && last.kind === 'ul') last.items.push(ulMatch[1]);
|
||||||
|
else blocks.push({ kind: 'ul', items: [ulMatch[1]] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const olMatch = /^\d+\.\s+(.+)$/.exec(trimmed);
|
||||||
|
if (olMatch) {
|
||||||
|
flushPara();
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (last && last.kind === 'ol') last.items.push(olMatch[1]);
|
||||||
|
else blocks.push({ kind: 'ol', items: [olMatch[1]] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteMatch = /^>\s?(.*)$/.exec(trimmed);
|
||||||
|
if (quoteMatch) {
|
||||||
|
flushPara();
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (last && last.kind === 'quote') last.lines.push(quoteMatch[1]);
|
||||||
|
else blocks.push({ kind: 'quote', lines: [quoteMatch[1]] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
para.push(line);
|
||||||
|
}
|
||||||
|
flushPara();
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.kind === 'p') {
|
||||||
|
out.push(`<p>${b.lines.map(inlineFormat).join('<br />')}</p>`);
|
||||||
|
} else if (b.kind === 'ul') {
|
||||||
|
out.push(`<ul>${b.items.map((i) => `<li>${inlineFormat(i)}</li>`).join('')}</ul>`);
|
||||||
|
} else if (b.kind === 'ol') {
|
||||||
|
out.push(`<ol>${b.items.map((i) => `<li>${inlineFormat(i)}</li>`).join('')}</ol>`);
|
||||||
|
} else {
|
||||||
|
out.push(`<blockquote>${b.lines.map(inlineFormat).join('<br />')}</blockquote>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the current selection of `text` in `before`/`after`. If nothing is
|
||||||
|
// selected, insert the markers at the caret. Returns the new text plus the
|
||||||
|
// new selection range.
|
||||||
|
export function wrapSelection(
|
||||||
|
text: string,
|
||||||
|
selection: { start: number; end: number },
|
||||||
|
before: string,
|
||||||
|
after: string,
|
||||||
|
placeholder = '',
|
||||||
|
): { text: string; selection: { start: number; end: number } } {
|
||||||
|
const start = Math.min(selection.start, selection.end);
|
||||||
|
const end = Math.max(selection.start, selection.end);
|
||||||
|
const middle = text.slice(start, end) || placeholder;
|
||||||
|
const next = `${text.slice(0, start)}${before}${middle}${after}${text.slice(end)}`;
|
||||||
|
const cursorStart = start + before.length;
|
||||||
|
const cursorEnd = cursorStart + middle.length;
|
||||||
|
return {
|
||||||
|
text: next,
|
||||||
|
selection: { start: cursorStart, end: cursorEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a line prefix (e.g. "- " or "> ") to the line(s) covered by selection.
|
||||||
|
// If a line already starts with the prefix, it's removed (toggle behavior).
|
||||||
|
export function toggleLinePrefix(
|
||||||
|
text: string,
|
||||||
|
selection: { start: number; end: number },
|
||||||
|
prefix: string,
|
||||||
|
): { text: string; selection: { start: number; end: number } } {
|
||||||
|
const start = Math.min(selection.start, selection.end);
|
||||||
|
const end = Math.max(selection.start, selection.end);
|
||||||
|
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
let lineEnd = text.indexOf('\n', end);
|
||||||
|
if (lineEnd === -1) lineEnd = text.length;
|
||||||
|
|
||||||
|
const block = text.slice(lineStart, lineEnd);
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const allHave = lines.every((l) => l.startsWith(prefix));
|
||||||
|
const next = lines
|
||||||
|
.map((l) => (allHave ? l.slice(prefix.length) : `${prefix}${l}`))
|
||||||
|
.join('\n');
|
||||||
|
const newText = `${text.slice(0, lineStart)}${next}${text.slice(lineEnd)}`;
|
||||||
|
const delta = next.length - block.length;
|
||||||
|
return {
|
||||||
|
text: newText,
|
||||||
|
selection: { start: lineStart, end: lineEnd + delta },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the quoted reply prefix the composer adds before the original body
|
||||||
|
// when sending a reply / forward. Used both for textBody and as input to the
|
||||||
|
// htmlBody renderer.
|
||||||
|
export function formatReplyQuote(
|
||||||
|
body: string,
|
||||||
|
meta: { senderName: string; date: Date },
|
||||||
|
): string {
|
||||||
|
const date = meta.date.toLocaleDateString();
|
||||||
|
const lines = body.split('\n').map((l) => `> ${l}`).join('\n');
|
||||||
|
return `\n\n---\nOn ${date}, ${meta.senderName} wrote:\n${lines}`;
|
||||||
|
}
|
||||||
@@ -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 unknown 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' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Animated, PanResponder } from 'react-native';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** Animated value driving the sheet's translateY. Resting (open) position is 0. */
|
||||||
|
slideY: Animated.Value;
|
||||||
|
/** Off-screen target the sheet animates to when dismissed. */
|
||||||
|
closedY: number;
|
||||||
|
/** Called when the gesture commits a dismissal. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Pixels of downward drag needed to commit a dismiss on release. */
|
||||||
|
closeDistance?: number;
|
||||||
|
/** Downward velocity (px/ms) that commits a dismiss regardless of distance. */
|
||||||
|
closeVelocity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires drag-to-dismiss on a bottom sheet's grab handle. Spread the returned
|
||||||
|
* panHandlers on the handle/header area — not the scrollable body — so vertical
|
||||||
|
* scrolling inside the sheet is not intercepted.
|
||||||
|
*/
|
||||||
|
export function useSheetDrag({
|
||||||
|
slideY,
|
||||||
|
closedY,
|
||||||
|
onClose,
|
||||||
|
closeDistance = 100,
|
||||||
|
closeVelocity = 0.6,
|
||||||
|
}: Options) {
|
||||||
|
const startRef = React.useRef(0);
|
||||||
|
|
||||||
|
const panResponder = React.useMemo(
|
||||||
|
() =>
|
||||||
|
PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: (_, g) =>
|
||||||
|
Math.abs(g.dy) > 4 && Math.abs(g.dy) > Math.abs(g.dx),
|
||||||
|
onPanResponderGrant: () => {
|
||||||
|
slideY.stopAnimation((v) => {
|
||||||
|
startRef.current = v;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPanResponderMove: (_, g) => {
|
||||||
|
const next = startRef.current + Math.max(0, g.dy);
|
||||||
|
slideY.setValue(next);
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, g) => {
|
||||||
|
const traveled = startRef.current + Math.max(0, g.dy);
|
||||||
|
if (traveled > closeDistance || g.vy > closeVelocity) {
|
||||||
|
Animated.timing(slideY, {
|
||||||
|
toValue: closedY,
|
||||||
|
duration: 180,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(({ finished }) => {
|
||||||
|
if (finished) onClose();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Animated.spring(slideY, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
bounciness: 0,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPanResponderTerminate: () => {
|
||||||
|
Animated.spring(slideY, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
bounciness: 0,
|
||||||
|
}).start();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[slideY, closedY, onClose, closeDistance, closeVelocity],
|
||||||
|
);
|
||||||
|
|
||||||
|
return panResponder.panHandlers;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
+517
-129
@@ -1,165 +1,553 @@
|
|||||||
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 { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
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 c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
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)}
|
||||||
</View>
|
hitSlop={8}
|
||||||
|
style={styles.headerBtn}
|
||||||
<View style={styles.monthNav}>
|
>
|
||||||
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
|
<Menu size={20} color={c.text} />
|
||||||
<ChevronLeft size={20} color={colors.text} />
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.monthLabel}>{format(currentDate, 'MMMM yyyy')}</Text>
|
<View style={styles.headerLeft}>
|
||||||
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
|
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||||
<ChevronRight size={20} color={colors.text} />
|
<Text style={styles.headerSubtitle}>
|
||||||
</Pressable>
|
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||||
</View>
|
</Text>
|
||||||
|
</View>
|
||||||
<MonthGrid currentDate={currentDate} />
|
<View style={styles.headerActions}>
|
||||||
|
<View style={styles.viewToggle}>
|
||||||
<View style={styles.todaySection}>
|
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||||
<Text style={styles.todaySectionTitle}>Today's Events</Text>
|
const Icon =
|
||||||
{MOCK_EVENTS.map((event) => (
|
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||||
<Pressable key={event.id} style={styles.eventCard}>
|
const active = viewMode === mode;
|
||||||
<View style={[styles.eventDot, { backgroundColor: event.color }]} />
|
return (
|
||||||
<View style={styles.eventContent}>
|
<Pressable
|
||||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
key={mode}
|
||||||
<Text style={styles.eventTime}>{event.time}</Text>
|
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||||
</View>
|
onPress={() => setViewMode(mode)}
|
||||||
|
>
|
||||||
|
<Icon size={16} color={active ? c.primary : c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||||
|
<Plus size={18} color={c.primaryForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.nav}>
|
||||||
|
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||||
|
<ChevronLeft size={20} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Button variant="outline" size="sm" onPress={goToday}>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||||
|
<ChevronRight size={20} color={c.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={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={c.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 c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
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={c.textMuted} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalendarDays size={32} color={c.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={c.textMuted} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dayEvents.map((event) => (
|
||||||
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
calendars={calendars}
|
||||||
|
onPress={onSelectEvent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.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: c.text },
|
||||||
weekdayRow: {
|
headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||||
|
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
|
viewToggle: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 8,
|
backgroundColor: c.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: c.background },
|
||||||
|
fab: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: c.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: c.errorBg,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
errorText: { ...typography.caption, color: c.errorForeground },
|
||||||
|
|
||||||
|
content: { flex: 1 },
|
||||||
|
|
||||||
|
dayDetail: {
|
||||||
|
flex: 1,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: c.border,
|
||||||
|
},
|
||||||
|
dayDetailHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
dayDetailTitle: { ...typography.bodyMedium, color: c.text },
|
||||||
|
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||||
|
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.xl,
|
||||||
|
gap: spacing.xs,
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
|
emptySubtitle: { ...typography.caption, color: c.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 },
|
|
||||||
});
|
|
||||||
+478
-84
@@ -2,17 +2,21 @@ import React from 'react';
|
|||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||||
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
||||||
|
type NativeSyntheticEvent, type TextInputSelectionChangeEventData,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import {
|
import {
|
||||||
X, Send, Paperclip, ChevronDown, Bold, Italic, List,
|
X, Send, Paperclip, ChevronDown, Bold, Italic, List, ListOrdered,
|
||||||
Link2, Image as ImageIcon, MoreHorizontal,
|
Link2, Image as ImageIcon, Quote, FileText,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
import { Button } from '../components';
|
import { Button } from '../components';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { isGroup } from '../lib/contact-utils';
|
import { isGroup } from '../lib/contact-utils';
|
||||||
import {
|
import {
|
||||||
getContactDisplayName,
|
getContactDisplayName,
|
||||||
@@ -20,7 +24,14 @@ import {
|
|||||||
matchesContactSearch,
|
matchesContactSearch,
|
||||||
} from '../lib/contact-utils';
|
} from '../lib/contact-utils';
|
||||||
import { getIdentities } from '../api/identity';
|
import { getIdentities } from '../api/identity';
|
||||||
import { sendEmail } from '../api/email';
|
import { sendEmail, type OutgoingAttachment } from '../api/email';
|
||||||
|
import { uploadBlob } from '../api/blob';
|
||||||
|
import {
|
||||||
|
markdownToHtml,
|
||||||
|
wrapSelection,
|
||||||
|
toggleLinePrefix,
|
||||||
|
formatReplyQuote,
|
||||||
|
} from '../lib/compose-format';
|
||||||
import type { EmailAddress, Identity, ContactCard } from '../api/types';
|
import type { EmailAddress, Identity, ContactCard } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
|
||||||
@@ -31,7 +42,21 @@ interface Recipient {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttachmentEntry = {
|
||||||
|
localId: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
uri: string;
|
||||||
|
inline: boolean;
|
||||||
|
cid?: string;
|
||||||
|
blobId?: string;
|
||||||
|
uploading: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const URL_RE = /^https?:\/\/.+/i;
|
||||||
|
|
||||||
function parseRecipients(input: string): Recipient[] {
|
function parseRecipients(input: string): Recipient[] {
|
||||||
return input
|
return input
|
||||||
@@ -41,14 +66,30 @@ function parseRecipients(input: string): Recipient[] {
|
|||||||
.map((email) => ({ name: '', email }));
|
.map((email) => ({ name: '', email }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genCid(): string {
|
||||||
|
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@bulwark.local`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genLocalId(): string {
|
||||||
|
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) {
|
function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
<Text style={styles.chipText} numberOfLines={1}>
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
{recipient.name || recipient.email}
|
{recipient.name || recipient.email}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={onRemove} hitSlop={8}>
|
<Pressable onPress={onRemove} hitSlop={8}>
|
||||||
<X size={12} color={colors.textMuted} />
|
<X size={12} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -60,6 +101,8 @@ function SuggestionList({
|
|||||||
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
|
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
|
||||||
onPick: (s: { name: string; email: string }) => void;
|
onPick: (s: { name: string; email: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.suggestionBox}>
|
<View style={styles.suggestionBox}>
|
||||||
{suggestions.map((s, i) => {
|
{suggestions.map((s, i) => {
|
||||||
@@ -88,7 +131,48 @@ function SuggestionList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AttachmentChip({
|
||||||
|
attachment, onRemove,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentEntry;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
return (
|
||||||
|
<View style={styles.attachmentChip}>
|
||||||
|
{attachment.uploading ? (
|
||||||
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
|
) : attachment.error ? (
|
||||||
|
<X size={14} color={c.error} />
|
||||||
|
) : attachment.type.startsWith('image/') ? (
|
||||||
|
<ImageIcon size={14} color={c.textSecondary} />
|
||||||
|
) : (
|
||||||
|
<FileText size={14} color={c.textSecondary} />
|
||||||
|
)}
|
||||||
|
<View style={styles.attachmentMeta}>
|
||||||
|
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||||
|
{attachment.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.attachmentSize}>
|
||||||
|
{attachment.error
|
||||||
|
? attachment.error
|
||||||
|
: attachment.uploading
|
||||||
|
? 'Uploading...'
|
||||||
|
: formatBytes(attachment.size)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={onRemove} hitSlop={8} style={styles.attachmentRemove}>
|
||||||
|
<X size={14} color={c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ComposeScreen({ route, navigation }: Props) {
|
export default function ComposeScreen({ route, navigation }: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
const replyTo = route.params?.replyTo;
|
const replyTo = route.params?.replyTo;
|
||||||
const mode = route.params?.mode;
|
const mode = route.params?.mode;
|
||||||
const prefillTo = route.params?.prefillTo;
|
const prefillTo = route.params?.prefillTo;
|
||||||
@@ -112,7 +196,6 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (mode === 'forward') return [];
|
if (mode === 'forward') return [];
|
||||||
// reply + replyAll both include the original sender
|
|
||||||
const base: Recipient[] = replyTo.from.email
|
const base: Recipient[] = replyTo.from.email
|
||||||
? [{ name: replyTo.from.name ?? '', email: replyTo.from.email }]
|
? [{ name: replyTo.from.name ?? '', email: replyTo.from.email }]
|
||||||
: [];
|
: [];
|
||||||
@@ -149,7 +232,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const [ccInput, setCcInput] = React.useState('');
|
const [ccInput, setCcInput] = React.useState('');
|
||||||
const [subject, setSubject] = React.useState(initialSubject);
|
const [subject, setSubject] = React.useState(initialSubject);
|
||||||
const [body, setBody] = React.useState('');
|
const [body, setBody] = React.useState('');
|
||||||
|
const [bodySelection, setBodySelection] = React.useState({ start: 0, end: 0 });
|
||||||
|
// Carry the next caret position from a programmatic edit so we can apply it
|
||||||
|
// imperatively after the body text has flushed. We avoid driving the
|
||||||
|
// TextInput's `selection` prop because that would pin the caret on every
|
||||||
|
// render and fight the user when they tap to move the cursor.
|
||||||
|
const pendingSelectionRef = React.useRef<{ start: number; end: number } | null>(null);
|
||||||
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
|
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
|
||||||
|
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>([]);
|
||||||
|
const bodyInputRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
const allContacts = useContactsStore((s) => s.contacts);
|
const allContacts = useContactsStore((s) => s.contacts);
|
||||||
const individuals = React.useMemo(() => allContacts.filter((c) => !isGroup(c)), [allContacts]);
|
const individuals = React.useMemo(() => allContacts.filter((c) => !isGroup(c)), [allContacts]);
|
||||||
@@ -228,24 +319,244 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { finalTo, finalCc } = commitTyped();
|
const { finalTo, finalCc } = commitTyped();
|
||||||
|
const hasUploadInFlight = attachments.some((a) => a.uploading);
|
||||||
|
const hasUploadError = attachments.some((a) => !!a.error);
|
||||||
const hasValidRecipients = finalTo.every((r) => EMAIL_RE.test(r.email)) && finalTo.length > 0;
|
const hasValidRecipients = finalTo.every((r) => EMAIL_RE.test(r.email)) && finalTo.length > 0;
|
||||||
const canSend =
|
const canSend =
|
||||||
!sending &&
|
!sending &&
|
||||||
|
!hasUploadInFlight &&
|
||||||
|
!hasUploadError &&
|
||||||
hasValidRecipients &&
|
hasValidRecipients &&
|
||||||
subject.trim().length > 0 &&
|
subject.trim().length > 0 &&
|
||||||
!!primaryIdentity &&
|
!!primaryIdentity &&
|
||||||
!!sentMailbox;
|
!!sentMailbox;
|
||||||
|
|
||||||
const onClose = () => navigation.goBack();
|
const onBodySelectionChange = (e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||||
|
setBodySelection(e.nativeEvent.selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply caret moves imperatively once the body text has flushed - using
|
||||||
|
// setNativeProps means we don't render the TextInput as selection-controlled
|
||||||
|
// and the user keeps free cursor control.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const target = pendingSelectionRef.current;
|
||||||
|
if (!target || !bodyInputRef.current) return;
|
||||||
|
pendingSelectionRef.current = null;
|
||||||
|
bodyInputRef.current.setNativeProps({ selection: target });
|
||||||
|
setBodySelection(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyEdit = (edit: { text: string; selection: { start: number; end: number } }) => {
|
||||||
|
pendingSelectionRef.current = edit.selection;
|
||||||
|
setBody(edit.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
if (body.trim() || subject.trim() || attachments.length > 0) {
|
||||||
|
Alert.alert(
|
||||||
|
t('email_composer.close_draft_title', 'Discard draft?'),
|
||||||
|
t('email_composer.discard_draft_confirm', 'You have unsaved changes. Do you want to discard this draft?'),
|
||||||
|
[
|
||||||
|
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: t('email_composer.discard', 'Discard'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => navigation.goBack(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAttachmentEntry = (entry: AttachmentEntry) => {
|
||||||
|
setAttachments((prev) => [...prev, entry]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAttachment = (localId: string, patch: Partial<AttachmentEntry>) => {
|
||||||
|
setAttachments((prev) => prev.map((a) => (a.localId === localId ? { ...a, ...patch } : a)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (localId: string) => {
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const removed = prev.find((a) => a.localId === localId);
|
||||||
|
// If an inline image is removed, also drop its markdown reference from
|
||||||
|
// the body so the user doesn't ship a dangling cid: link.
|
||||||
|
if (removed?.inline && removed.cid) {
|
||||||
|
const ref = ``;
|
||||||
|
if (body.includes(ref)) setBody((b) => b.replace(ref, '').replace(/\n{3,}/g, '\n\n'));
|
||||||
|
}
|
||||||
|
return prev.filter((a) => a.localId !== localId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startUpload = async (asset: {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
uri: string;
|
||||||
|
inline: boolean;
|
||||||
|
}) => {
|
||||||
|
const localId = genLocalId();
|
||||||
|
const cid = asset.inline ? genCid() : undefined;
|
||||||
|
const entry: AttachmentEntry = {
|
||||||
|
localId,
|
||||||
|
name: asset.name,
|
||||||
|
type: asset.type,
|
||||||
|
size: asset.size,
|
||||||
|
uri: asset.uri,
|
||||||
|
inline: asset.inline,
|
||||||
|
cid,
|
||||||
|
uploading: true,
|
||||||
|
};
|
||||||
|
addAttachmentEntry(entry);
|
||||||
|
|
||||||
|
if (asset.inline && cid) {
|
||||||
|
// Insert a markdown reference at the caret so the user can move it.
|
||||||
|
const ref = `\n\n`;
|
||||||
|
const next = wrapSelection(body, bodySelection, ref, '', '');
|
||||||
|
applyEdit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { blobId, size, type } = await uploadBlob(asset.uri, asset.type);
|
||||||
|
updateAttachment(localId, {
|
||||||
|
blobId,
|
||||||
|
type: type || asset.type,
|
||||||
|
size: size || asset.size,
|
||||||
|
uploading: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'Upload failed';
|
||||||
|
updateAttachment(localId, { uploading: false, error: message });
|
||||||
|
Alert.alert(
|
||||||
|
t('email_composer.send_failed', 'Failed to send email'),
|
||||||
|
t('email_composer.upload_failed', 'Failed to upload {filename}').replace('{filename}', asset.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickAttachment = async (inline: boolean) => {
|
||||||
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert(
|
||||||
|
t('email_composer.attach', 'Attach'),
|
||||||
|
'Photo library permission is required to attach images.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: inline ? ['images'] : ['images', 'videos'],
|
||||||
|
allowsMultipleSelection: !inline,
|
||||||
|
quality: 0.9,
|
||||||
|
exif: false,
|
||||||
|
});
|
||||||
|
if (result.canceled) return;
|
||||||
|
for (const asset of result.assets) {
|
||||||
|
const fallbackName = asset.fileName
|
||||||
|
|| `${inline ? 'image' : 'attachment'}-${Date.now()}.${(asset.mimeType ?? 'image/jpeg').split('/')[1] ?? 'bin'}`;
|
||||||
|
void startUpload({
|
||||||
|
name: fallbackName,
|
||||||
|
type: asset.mimeType ?? 'application/octet-stream',
|
||||||
|
size: asset.fileSize ?? 0,
|
||||||
|
uri: asset.uri,
|
||||||
|
inline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Format toolbar handlers ────────────────────────────────────────────
|
||||||
|
const formatBold = () => {
|
||||||
|
applyEdit(wrapSelection(body, bodySelection, '**', '**', 'bold'));
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
const formatItalic = () => {
|
||||||
|
applyEdit(wrapSelection(body, bodySelection, '_', '_', 'italic'));
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
const formatBullet = () => {
|
||||||
|
applyEdit(toggleLinePrefix(body, bodySelection, '- '));
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
const formatNumbered = () => {
|
||||||
|
applyEdit(toggleLinePrefix(body, bodySelection, '1. '));
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
const formatQuote = () => {
|
||||||
|
applyEdit(toggleLinePrefix(body, bodySelection, '> '));
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
const insertLink = (url: string) => {
|
||||||
|
const target = URL_RE.test(url) ? url : `https://${url}`;
|
||||||
|
const start = Math.min(bodySelection.start, bodySelection.end);
|
||||||
|
const end = Math.max(bodySelection.start, bodySelection.end);
|
||||||
|
const selected = body.slice(start, end);
|
||||||
|
const label = selected || target;
|
||||||
|
const insertion = `[${label}](${target})`;
|
||||||
|
const next = `${body.slice(0, start)}${insertion}${body.slice(end)}`;
|
||||||
|
const cursor = start + insertion.length;
|
||||||
|
applyEdit({ text: next, selection: { start: cursor, end: cursor } });
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLink = () => {
|
||||||
|
if (Platform.OS === 'ios' && typeof Alert.prompt === 'function') {
|
||||||
|
Alert.prompt(
|
||||||
|
t('email_composer.add_link', 'Add link'),
|
||||||
|
t('email_composer.link_url_prompt', 'Enter the URL'),
|
||||||
|
(url) => { if (url) insertLink(url); },
|
||||||
|
'plain-text',
|
||||||
|
'',
|
||||||
|
'url',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Android / web: insert an editable stub with the URL portion selected.
|
||||||
|
const stub = '[link text](https://)';
|
||||||
|
const start = Math.min(bodySelection.start, bodySelection.end);
|
||||||
|
const end = Math.max(bodySelection.start, bodySelection.end);
|
||||||
|
const next = `${body.slice(0, start)}${stub}${body.slice(end)}`;
|
||||||
|
const urlStart = start + '[link text]('.length;
|
||||||
|
const urlEnd = start + stub.length - 1;
|
||||||
|
applyEdit({ text: next, selection: { start: urlStart, end: urlEnd } });
|
||||||
|
bodyInputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
const onSend = async () => {
|
const onSend = async () => {
|
||||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
||||||
const bodyWithQuote = replyTo?.body
|
|
||||||
? `${body}\n\n---\nOn ${new Date().toLocaleDateString()}, ${replyTo.from.name || replyTo.from.email} wrote:\n${replyTo.body.split('\n').map((l) => `> ${l}`).join('\n')}`
|
const quote = replyTo?.body
|
||||||
: body;
|
? formatReplyQuote(replyTo.body, {
|
||||||
|
senderName: replyTo.from.name || replyTo.from.email,
|
||||||
|
date: new Date(),
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const sourceText = body + quote;
|
||||||
|
const htmlBody = markdownToHtml(sourceText);
|
||||||
|
|
||||||
|
// Plain text fallback: keep the user's source as-is. Strip our basic
|
||||||
|
// markdown markers so receivers without HTML get clean text.
|
||||||
|
const plainText = sourceText
|
||||||
|
.replace(/!\[([^\]]*)\]\(cid:[^)]+\)/g, '[$1]')
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
||||||
|
.replace(/\*\*([^*\n]+)\*\*/g, '$1')
|
||||||
|
.replace(/(^|[^*\w])_([^_\n]+)_(?=$|[^*\w])/g, '$1$2');
|
||||||
|
|
||||||
|
const outgoing: OutgoingAttachment[] = attachments
|
||||||
|
.filter((a) => a.blobId && !a.error)
|
||||||
|
.map((a) => ({
|
||||||
|
blobId: a.blobId!,
|
||||||
|
type: a.type,
|
||||||
|
name: a.name,
|
||||||
|
size: a.size,
|
||||||
|
disposition: a.inline ? 'inline' : 'attachment',
|
||||||
|
cid: a.cid,
|
||||||
|
}));
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
{
|
{
|
||||||
@@ -255,7 +566,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
|
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
|
||||||
: undefined,
|
: undefined,
|
||||||
subject,
|
subject,
|
||||||
textBody: bodyWithQuote,
|
htmlBody,
|
||||||
|
textBody: plainText,
|
||||||
|
attachments: outgoing.length ? outgoing : undefined,
|
||||||
inReplyTo: replyTo?.inReplyTo,
|
inReplyTo: replyTo?.inReplyTo,
|
||||||
references: replyTo?.references,
|
references: replyTo?.references,
|
||||||
},
|
},
|
||||||
@@ -264,24 +577,37 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
);
|
);
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert('Send failed', e instanceof Error ? e.message : 'Failed to send email');
|
Alert.alert(
|
||||||
|
t('email_composer.send_failed', 'Send failed'),
|
||||||
|
e instanceof Error ? e.message : 'Failed to send email',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleKey =
|
||||||
|
mode === 'forward' ? 'email_composer.forward'
|
||||||
|
: mode === 'replyAll' ? 'email_composer.reply_all'
|
||||||
|
: replyTo ? 'email_composer.reply'
|
||||||
|
: 'email_composer.new_message';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={onClose} style={styles.headerBtn}>
|
<Pressable onPress={onClose} style={styles.headerBtn}>
|
||||||
<X size={22} color={colors.text} />
|
<X size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>
|
<Text style={styles.headerTitle}>
|
||||||
{mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message'}
|
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<Pressable style={styles.headerBtn}>
|
<Pressable
|
||||||
<Paperclip size={20} color={colors.text} />
|
onPress={() => { void pickAttachment(false); }}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Paperclip size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -290,17 +616,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
icon={
|
icon={
|
||||||
sending ? (
|
sending ? (
|
||||||
<ActivityIndicator color={colors.primaryForeground} size="small" />
|
<ActivityIndicator color={c.primaryForeground} size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Send
|
<Send
|
||||||
size={14}
|
size={14}
|
||||||
color={canSend ? colors.primaryForeground : colors.textMuted}
|
color={canSend ? c.primaryForeground : c.textMuted}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
style={!canSend ? styles.sendButtonDisabled : undefined}
|
style={!canSend ? styles.sendButtonDisabled : undefined}
|
||||||
>
|
>
|
||||||
{sending ? 'Sending...' : 'Send'}
|
{sending ? t('email_composer.sending', 'Sending...') : t('email_composer.send', 'Send')}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -311,17 +637,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
>
|
>
|
||||||
<ScrollView style={styles.flex} keyboardShouldPersistTaps="handled">
|
<ScrollView style={styles.flex} keyboardShouldPersistTaps="handled">
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldLabel}>From</Text>
|
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
|
||||||
<View style={styles.fieldContent}>
|
<View style={styles.fieldContent}>
|
||||||
<Text style={styles.fromText} numberOfLines={1}>
|
<Text style={styles.fromText} numberOfLines={1}>
|
||||||
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
|
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={14} color={colors.textMuted} />
|
<ChevronDown size={14} color={c.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldLabel}>To</Text>
|
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
|
||||||
<View style={styles.recipientField}>
|
<View style={styles.recipientField}>
|
||||||
{toRecipients.map((r, i) => (
|
{toRecipients.map((r, i) => (
|
||||||
<RecipientChip
|
<RecipientChip
|
||||||
@@ -332,8 +658,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
))}
|
))}
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.recipientInput}
|
style={styles.recipientInput}
|
||||||
placeholder="Add recipient..."
|
placeholder={t('email_composer.to_placeholder', 'Recipient email addresses')}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={toInput}
|
value={toInput}
|
||||||
onChangeText={setToInput}
|
onChangeText={setToInput}
|
||||||
onFocus={() => setActiveField('to')}
|
onFocus={() => setActiveField('to')}
|
||||||
@@ -352,14 +678,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
{!ccVisible && (
|
{!ccVisible && (
|
||||||
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
|
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
|
||||||
<Text style={styles.ccToggleText}>Cc</Text>
|
<Text style={styles.ccToggleText}>{t('email_composer.cc', 'Cc')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{ccVisible && (
|
{ccVisible && (
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldLabel}>Cc</Text>
|
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
|
||||||
<View style={styles.recipientField}>
|
<View style={styles.recipientField}>
|
||||||
{ccRecipients.map((r, i) => (
|
{ccRecipients.map((r, i) => (
|
||||||
<RecipientChip
|
<RecipientChip
|
||||||
@@ -370,8 +696,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
))}
|
))}
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.recipientInput}
|
style={styles.recipientInput}
|
||||||
placeholder="Add Cc..."
|
placeholder={t('email_composer.cc_placeholder', 'Cc recipients')}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={ccInput}
|
value={ccInput}
|
||||||
onChangeText={setCcInput}
|
onChangeText={setCcInput}
|
||||||
onFocus={() => setActiveField('cc')}
|
onFocus={() => setActiveField('cc')}
|
||||||
@@ -392,24 +718,39 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.fieldRow}>
|
<View style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldLabel}>Subject</Text>
|
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.subjectInput}
|
style={styles.subjectInput}
|
||||||
placeholder="Email subject"
|
placeholder={t('email_composer.subject_placeholder', 'Subject')}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={subject}
|
value={subject}
|
||||||
onChangeText={setSubject}
|
onChangeText={setSubject}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<View style={styles.attachmentList}>
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<AttachmentChip
|
||||||
|
key={a.localId}
|
||||||
|
attachment={a}
|
||||||
|
onRemove={() => removeAttachment(a.localId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={bodyInputRef}
|
||||||
style={styles.bodyInput}
|
style={styles.bodyInput}
|
||||||
placeholder="Write your message..."
|
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={body}
|
value={body}
|
||||||
onChangeText={setBody}
|
onChangeText={setBody}
|
||||||
|
onSelectionChange={onBodySelectionChange}
|
||||||
multiline
|
multiline
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
|
autoCapitalize="sentences"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{replyTo?.body && (
|
{replyTo?.body && (
|
||||||
@@ -417,7 +758,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
<View style={styles.quoteBorder} />
|
<View style={styles.quoteBorder} />
|
||||||
<View style={styles.quoteContent}>
|
<View style={styles.quoteContent}>
|
||||||
<Text style={styles.quoteMeta}>
|
<Text style={styles.quoteMeta}>
|
||||||
On {new Date().toLocaleDateString()}, {replyTo.from.name || replyTo.from.email} wrote:
|
{t('email_composer.quote.reply_header', 'On {date}, {sender} wrote:')
|
||||||
|
.replace('{date}', new Date().toLocaleDateString())
|
||||||
|
.replace('{sender}', replyTo.from.name || replyTo.from.email)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.quoteText} numberOfLines={6}>
|
<Text style={styles.quoteText} numberOfLines={6}>
|
||||||
{replyTo.body}
|
{replyTo.body}
|
||||||
@@ -427,35 +770,56 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={styles.formatBar}>
|
<ScrollView
|
||||||
<View style={styles.formatActions}>
|
horizontal
|
||||||
<Pressable style={styles.formatBtn}>
|
showsHorizontalScrollIndicator={false}
|
||||||
<Bold size={18} color={colors.textSecondary} />
|
style={styles.formatBar}
|
||||||
</Pressable>
|
contentContainerStyle={styles.formatActions}
|
||||||
<Pressable style={styles.formatBtn}>
|
keyboardShouldPersistTaps="handled"
|
||||||
<Italic size={18} color={colors.textSecondary} />
|
>
|
||||||
</Pressable>
|
<Pressable style={styles.formatBtn} onPress={formatBold} hitSlop={4}>
|
||||||
<Pressable style={styles.formatBtn}>
|
<Bold size={18} color={c.textSecondary} />
|
||||||
<List size={18} color={colors.textSecondary} />
|
</Pressable>
|
||||||
</Pressable>
|
<Pressable style={styles.formatBtn} onPress={formatItalic} hitSlop={4}>
|
||||||
<Pressable style={styles.formatBtn}>
|
<Italic size={18} color={c.textSecondary} />
|
||||||
<Link2 size={18} color={colors.textSecondary} />
|
</Pressable>
|
||||||
</Pressable>
|
<View style={styles.formatSep} />
|
||||||
<Pressable style={styles.formatBtn}>
|
<Pressable style={styles.formatBtn} onPress={formatBullet} hitSlop={4}>
|
||||||
<ImageIcon size={18} color={colors.textSecondary} />
|
<List size={18} color={c.textSecondary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.formatBtn}>
|
<Pressable style={styles.formatBtn} onPress={formatNumbered} hitSlop={4}>
|
||||||
<MoreHorizontal size={18} color={colors.textSecondary} />
|
<ListOrdered size={18} color={c.textSecondary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
<Pressable style={styles.formatBtn} onPress={formatQuote} hitSlop={4}>
|
||||||
</View>
|
<Quote size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.formatSep} />
|
||||||
|
<Pressable style={styles.formatBtn} onPress={formatLink} hitSlop={4}>
|
||||||
|
<Link2 size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={styles.formatBtn}
|
||||||
|
onPress={() => { void pickAttachment(true); }}
|
||||||
|
hitSlop={4}
|
||||||
|
>
|
||||||
|
<ImageIcon size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={styles.formatBtn}
|
||||||
|
onPress={() => { void pickAttachment(false); }}
|
||||||
|
hitSlop={4}
|
||||||
|
>
|
||||||
|
<Paperclip size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
flex: { flex: 1 },
|
flex: { flex: 1 },
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
@@ -465,7 +829,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
@@ -475,7 +839,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
|
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
headerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
sendButtonDisabled: { opacity: 0.5 },
|
sendButtonDisabled: { opacity: 0.5 },
|
||||||
|
|
||||||
@@ -485,12 +849,12 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
fieldLabel: {
|
fieldLabel: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
width: 56,
|
width: 56,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
},
|
},
|
||||||
@@ -501,7 +865,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
fromText: { ...typography.body, color: colors.text },
|
fromText: { ...typography.body, color: c.text },
|
||||||
recipientField: {
|
recipientField: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -517,24 +881,24 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.surfaceActive,
|
backgroundColor: c.surfaceActive,
|
||||||
maxWidth: 220,
|
maxWidth: 220,
|
||||||
},
|
},
|
||||||
chipText: { ...typography.caption, color: colors.text, flexShrink: 1 },
|
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||||
recipientInput: {
|
recipientInput: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
suggestionBox: {
|
suggestionBox: {
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
marginLeft: 60,
|
marginLeft: 60,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.borderLight,
|
borderColor: c.borderLight,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
suggestionRow: {
|
suggestionRow: {
|
||||||
@@ -544,33 +908,54 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
suggestionRowPressed: { backgroundColor: colors.surfaceHover },
|
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
||||||
suggestionAvatar: {
|
suggestionAvatar: {
|
||||||
width: 32, height: 32,
|
width: 32, height: 32,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
suggestionAvatarText: { ...typography.captionMedium, color: colors.primary },
|
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
|
||||||
suggestionText: { flex: 1 },
|
suggestionText: { flex: 1 },
|
||||||
suggestionName: { ...typography.bodyMedium, color: colors.text },
|
suggestionName: { ...typography.bodyMedium, color: c.text },
|
||||||
suggestionEmail: { ...typography.caption, color: colors.textSecondary },
|
suggestionEmail: { ...typography.caption, color: c.textSecondary },
|
||||||
ccToggle: {
|
ccToggle: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
ccToggleText: { ...typography.caption, color: colors.primary },
|
ccToggleText: { ...typography.caption, color: c.primary },
|
||||||
subjectInput: {
|
subjectInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
attachmentList: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
gap: spacing.xs,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.borderLight,
|
||||||
|
},
|
||||||
|
attachmentChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
backgroundColor: c.surfaceActive,
|
||||||
|
},
|
||||||
|
attachmentMeta: { flex: 1, minWidth: 0 },
|
||||||
|
attachmentName: { ...typography.bodyMedium, color: c.text },
|
||||||
|
attachmentSize: { ...typography.caption, color: c.textMuted },
|
||||||
|
attachmentRemove: { padding: 4 },
|
||||||
|
|
||||||
bodyInput: {
|
bodyInput: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
minHeight: 200,
|
minHeight: 200,
|
||||||
@@ -583,21 +968,23 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
quoteBorder: { width: 3, backgroundColor: colors.border, borderRadius: radius.xs },
|
quoteBorder: { width: 3, backgroundColor: c.border, borderRadius: radius.xs },
|
||||||
quoteContent: { flex: 1 },
|
quoteContent: { flex: 1 },
|
||||||
quoteMeta: { ...typography.caption, color: colors.textMuted, marginBottom: spacing.xs },
|
quoteMeta: { ...typography.caption, color: c.textMuted, marginBottom: spacing.xs },
|
||||||
quoteText: { ...typography.caption, color: colors.textSecondary, lineHeight: 18 },
|
quoteText: { ...typography.caption, color: c.textSecondary, lineHeight: 18 },
|
||||||
|
|
||||||
formatBar: {
|
formatBar: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
paddingHorizontal: spacing.md,
|
flexGrow: 0,
|
||||||
paddingVertical: spacing.xs,
|
|
||||||
},
|
},
|
||||||
formatActions: {
|
formatActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
},
|
},
|
||||||
formatBtn: {
|
formatBtn: {
|
||||||
width: componentSizes.buttonMd,
|
width: componentSizes.buttonMd,
|
||||||
@@ -606,4 +993,11 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
});
|
formatSep: {
|
||||||
|
width: 1,
|
||||||
|
height: 20,
|
||||||
|
backgroundColor: c.borderLight,
|
||||||
|
marginHorizontal: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,29 +24,36 @@ import { contactToVCard } from '../lib/vcard';
|
|||||||
import SenderAvatar from '../components/SenderAvatar';
|
import SenderAvatar from '../components/SenderAvatar';
|
||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
import { ContactActivity } from '../components/contacts/ContactActivity';
|
import { ContactActivity } from '../components/contacts/ContactActivity';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
||||||
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
type Category = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'notes';
|
||||||
contact: colors.primary,
|
|
||||||
work: colors.calendar.orange,
|
function categoryColor(c: ThemePalette, category: Category): string {
|
||||||
location: colors.calendar.green,
|
switch (category) {
|
||||||
personal: colors.calendar.pink,
|
case 'contact': return c.primary;
|
||||||
digital: colors.calendar.teal,
|
case 'work': return c.calendar.orange;
|
||||||
notes: colors.calendar.purple,
|
case 'location': return c.calendar.green;
|
||||||
};
|
case 'personal': return c.calendar.pink;
|
||||||
|
case 'digital': return c.calendar.teal;
|
||||||
|
case 'notes': return c.calendar.purple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
icon, title, category = 'contact', children,
|
icon, title, category = 'contact', children,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
category?: keyof typeof CATEGORY_COLORS;
|
category?: Category;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const accent = CATEGORY_COLORS[category] || colors.primary;
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const accent = categoryColor(c, category);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.section, { borderLeftColor: accent }]}>
|
<View style={[styles.section, { borderLeftColor: accent }]}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
@@ -59,6 +66,8 @@ function Section({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ContextChips({ contexts }: { contexts?: Record<string, boolean> }) {
|
function ContextChips({ contexts }: { contexts?: Record<string, boolean> }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
if (!contexts) return null;
|
if (!contexts) return null;
|
||||||
const keys = Object.keys(contexts).filter((k) => contexts[k]);
|
const keys = Object.keys(contexts).filter((k) => contexts[k]);
|
||||||
if (keys.length === 0) return null;
|
if (keys.length === 0) return null;
|
||||||
@@ -74,6 +83,8 @@ function ContextChips({ contexts }: { contexts?: Record<string, boolean> }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactDetailScreen() {
|
export default function ContactDetailScreen() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const route = useRoute<Route>();
|
const route = useRoute<Route>();
|
||||||
const { contactId } = route.params;
|
const { contactId } = route.params;
|
||||||
@@ -90,7 +101,7 @@ export default function ContactDetailScreen() {
|
|||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={colors.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>Contact</Text>
|
<Text style={styles.headerTitle}>Contact</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -183,7 +194,7 @@ export default function ContactDetailScreen() {
|
|||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={colors.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
@@ -193,7 +204,7 @@ export default function ContactDetailScreen() {
|
|||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Copy size={18} color={colors.text} />
|
<Copy size={18} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -201,14 +212,14 @@ export default function ContactDetailScreen() {
|
|||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Edit2 size={18} color={colors.text} />
|
<Edit2 size={18} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setConfirmDelete(true)}
|
onPress={() => setConfirmDelete(true)}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Trash2 size={18} color={colors.error} />
|
<Trash2 size={18} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -227,17 +238,17 @@ export default function ContactDetailScreen() {
|
|||||||
|
|
||||||
<View style={styles.quickActions}>
|
<View style={styles.quickActions}>
|
||||||
{!!email && (
|
{!!email && (
|
||||||
<QuickAction icon={<Mail size={18} color={colors.primary} />} label="Email" onPress={() => openMail(email)} />
|
<QuickAction icon={<Mail size={18} color={c.primary} />} label="Email" onPress={() => openMail(email)} />
|
||||||
)}
|
)}
|
||||||
{!!phone && (
|
{!!phone && (
|
||||||
<QuickAction icon={<Phone size={18} color={colors.primary} />} label="Call" onPress={() => openTel(phone)} />
|
<QuickAction icon={<Phone size={18} color={c.primary} />} label="Call" onPress={() => openTel(phone)} />
|
||||||
)}
|
)}
|
||||||
{!!phone && (
|
{!!phone && (
|
||||||
<QuickAction icon={<MessageSquare size={18} color={colors.primary} />} label="SMS" onPress={() => openSms(phone)} />
|
<QuickAction icon={<MessageSquare size={18} color={c.primary} />} label="SMS" onPress={() => openSms(phone)} />
|
||||||
)}
|
)}
|
||||||
{!!name && (
|
{!!name && (
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<Share2 size={18} color={colors.primary} />}
|
icon={<Share2 size={18} color={c.primary} />}
|
||||||
label="Share"
|
label="Share"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const vcard = contactToVCard(contact);
|
const vcard = contactToVCard(contact);
|
||||||
@@ -263,7 +274,7 @@ export default function ContactDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{emails.length > 0 && (
|
{emails.length > 0 && (
|
||||||
<Section icon={<Mail size={14} color={colors.primary} />} title="Email" category="contact">
|
<Section icon={<Mail size={14} color={c.primary} />} title="Email" category="contact">
|
||||||
{emails.map((e) => (
|
{emails.map((e) => (
|
||||||
<Pressable key={e.id} onPress={() => openMail(e.address)} style={styles.fieldRow}>
|
<Pressable key={e.id} onPress={() => openMail(e.address)} style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldValue} selectable>{e.address}</Text>
|
<Text style={styles.fieldValue} selectable>{e.address}</Text>
|
||||||
@@ -275,7 +286,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{phones.length > 0 && (
|
{phones.length > 0 && (
|
||||||
<Section icon={<Phone size={14} color={colors.primary} />} title="Phone" category="contact">
|
<Section icon={<Phone size={14} color={c.primary} />} title="Phone" category="contact">
|
||||||
{phones.map((p) => (
|
{phones.map((p) => (
|
||||||
<Pressable key={p.id} onPress={() => openTel(p.number)} style={styles.fieldRow}>
|
<Pressable key={p.id} onPress={() => openTel(p.number)} style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldValue} selectable>{p.number}</Text>
|
<Text style={styles.fieldValue} selectable>{p.number}</Text>
|
||||||
@@ -287,7 +298,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{orgs.length > 0 && (
|
{orgs.length > 0 && (
|
||||||
<Section icon={<Building size={14} color={colors.calendar.orange} />} title="Organization" category="work">
|
<Section icon={<Building size={14} color={c.calendar.orange} />} title="Organization" category="work">
|
||||||
{orgs.map((o, i) => (
|
{orgs.map((o, i) => (
|
||||||
<View key={i} style={styles.fieldRow}>
|
<View key={i} style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldValue}>{o.name}</Text>
|
<Text style={styles.fieldValue}>{o.name}</Text>
|
||||||
@@ -300,7 +311,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{titles.length > 0 && (
|
{titles.length > 0 && (
|
||||||
<Section icon={<Briefcase size={14} color={colors.calendar.orange} />} title="Title" category="work">
|
<Section icon={<Briefcase size={14} color={c.calendar.orange} />} title="Title" category="work">
|
||||||
{titles.map((tl, i) => (
|
{titles.map((tl, i) => (
|
||||||
<View key={i} style={styles.fieldRow}>
|
<View key={i} style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldValue}>{tl.name}</Text>
|
<Text style={styles.fieldValue}>{tl.name}</Text>
|
||||||
@@ -311,7 +322,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{addresses.length > 0 && (
|
{addresses.length > 0 && (
|
||||||
<Section icon={<MapPin size={14} color={colors.calendar.green} />} title="Addresses" category="location">
|
<Section icon={<MapPin size={14} color={c.calendar.green} />} title="Addresses" category="location">
|
||||||
{addresses.map((a) => {
|
{addresses.map((a) => {
|
||||||
const formatted = formatAddress(a);
|
const formatted = formatAddress(a);
|
||||||
return (
|
return (
|
||||||
@@ -325,7 +336,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{anniversaries.length > 0 && (
|
{anniversaries.length > 0 && (
|
||||||
<Section icon={<Cake size={14} color={colors.calendar.pink} />} title="Anniversaries" category="personal">
|
<Section icon={<Cake size={14} color={c.calendar.pink} />} title="Anniversaries" category="personal">
|
||||||
{anniversaries.map((a, i) => (
|
{anniversaries.map((a, i) => (
|
||||||
<View key={i} style={styles.fieldRow}>
|
<View key={i} style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldValue}>{formatPartialDate(a.date)}</Text>
|
<Text style={styles.fieldValue}>{formatPartialDate(a.date)}</Text>
|
||||||
@@ -336,7 +347,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{onlineServices.length > 0 && (
|
{onlineServices.length > 0 && (
|
||||||
<Section icon={<Globe size={14} color={colors.calendar.teal} />} title="Online" category="digital">
|
<Section icon={<Globe size={14} color={c.calendar.teal} />} title="Online" category="digital">
|
||||||
{onlineServices.map((s, i) => (
|
{onlineServices.map((s, i) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={i}
|
key={i}
|
||||||
@@ -352,7 +363,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{personalInfo.length > 0 && (
|
{personalInfo.length > 0 && (
|
||||||
<Section icon={<Heart size={14} color={colors.calendar.pink} />} title="Personal" category="personal">
|
<Section icon={<Heart size={14} color={c.calendar.pink} />} title="Personal" category="personal">
|
||||||
{personalInfo.map((pi, i) => (
|
{personalInfo.map((pi, i) => (
|
||||||
<View key={i} style={styles.fieldRow}>
|
<View key={i} style={styles.fieldRow}>
|
||||||
<Text style={styles.fieldValue}>{pi.value}</Text>
|
<Text style={styles.fieldValue}>{pi.value}</Text>
|
||||||
@@ -363,7 +374,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{relatedTo.length > 0 && (
|
{relatedTo.length > 0 && (
|
||||||
<Section icon={<Users size={14} color={colors.calendar.pink} />} title="Related" category="personal">
|
<Section icon={<Users size={14} color={c.calendar.pink} />} title="Related" category="personal">
|
||||||
{relatedTo.map(([uri, rel], i) => {
|
{relatedTo.map(([uri, rel], i) => {
|
||||||
const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined;
|
const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined;
|
||||||
return (
|
return (
|
||||||
@@ -377,7 +388,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{notes.length > 0 && (
|
{notes.length > 0 && (
|
||||||
<Section icon={<FileText size={14} color={colors.calendar.purple} />} title="Notes" category="notes">
|
<Section icon={<FileText size={14} color={c.calendar.purple} />} title="Notes" category="notes">
|
||||||
{notes.map((n, i) => (
|
{notes.map((n, i) => (
|
||||||
<Text key={i} style={styles.noteText}>{n.note}</Text>
|
<Text key={i} style={styles.noteText}>{n.note}</Text>
|
||||||
))}
|
))}
|
||||||
@@ -385,7 +396,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{keywords.length > 0 && (
|
{keywords.length > 0 && (
|
||||||
<Section icon={<Tag size={14} color={colors.calendar.teal} />} title="Tags" category="digital">
|
<Section icon={<Tag size={14} color={c.calendar.teal} />} title="Tags" category="digital">
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow}>
|
||||||
{keywords.map((kw) => (
|
{keywords.map((kw) => (
|
||||||
<View key={kw} style={styles.tagChip}>
|
<View key={kw} style={styles.tagChip}>
|
||||||
@@ -397,7 +408,7 @@ export default function ContactDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{bookNames.length > 0 && (
|
{bookNames.length > 0 && (
|
||||||
<Section icon={<BookUser size={14} color={colors.textSecondary} />} title="Address Book" category="contact">
|
<Section icon={<BookUser size={14} color={c.textSecondary} />} title="Address Book" category="contact">
|
||||||
{bookNames.map((n, i) => (
|
{bookNames.map((n, i) => (
|
||||||
<Text key={i} style={styles.fieldValue}>{n}</Text>
|
<Text key={i} style={styles.fieldValue}>{n}</Text>
|
||||||
))}
|
))}
|
||||||
@@ -427,6 +438,8 @@ function QuickAction({
|
|||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable style={({ pressed }) => [styles.quickBtn, pressed && styles.quickBtnPressed]} onPress={onPress}>
|
<Pressable style={({ pressed }) => [styles.quickBtn, pressed && styles.quickBtnPressed]} onPress={onPress}>
|
||||||
<View style={styles.quickIcon}>{icon}</View>
|
<View style={styles.quickIcon}>{icon}</View>
|
||||||
@@ -435,8 +448,9 @@ function QuickAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
scrollContent: { paddingBottom: spacing.xxxl * 2 },
|
scrollContent: { paddingBottom: spacing.xxxl * 2 },
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
@@ -446,14 +460,14 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
width: 40, height: 40,
|
width: 40, height: 40,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
|
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
|
||||||
headerActions: { flexDirection: 'row', gap: spacing.xs },
|
headerActions: { flexDirection: 'row', gap: spacing.xs },
|
||||||
|
|
||||||
hero: {
|
hero: {
|
||||||
@@ -466,10 +480,10 @@ const styles = StyleSheet.create({
|
|||||||
width: 96,
|
width: 96,
|
||||||
height: 96,
|
height: 96,
|
||||||
borderRadius: 48,
|
borderRadius: 48,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
heroName: { ...typography.h2, color: colors.text, textAlign: 'center', marginTop: spacing.sm },
|
heroName: { ...typography.h2, color: c.text, textAlign: 'center', marginTop: spacing.sm },
|
||||||
heroSubtitle: { ...typography.body, color: colors.textSecondary, textAlign: 'center' },
|
heroSubtitle: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
|
||||||
|
|
||||||
quickActions: {
|
quickActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -483,17 +497,17 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
quickBtnPressed: { backgroundColor: colors.surfaceHover },
|
quickBtnPressed: { backgroundColor: c.surfaceHover },
|
||||||
quickIcon: {
|
quickIcon: {
|
||||||
width: 32, height: 32,
|
width: 32, height: 32,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
quickLabel: { ...typography.caption, color: colors.textSecondary },
|
quickLabel: { ...typography.caption, color: c.textSecondary },
|
||||||
|
|
||||||
section: {
|
section: {
|
||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
@@ -502,33 +516,34 @@ const styles = StyleSheet.create({
|
|||||||
paddingLeft: spacing.md,
|
paddingLeft: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.card,
|
backgroundColor: c.card,
|
||||||
},
|
},
|
||||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginBottom: spacing.sm },
|
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginBottom: spacing.sm },
|
||||||
sectionTitle: { ...typography.bodySemibold, color: colors.text },
|
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||||
sectionBody: { gap: spacing.sm },
|
sectionBody: { gap: spacing.sm },
|
||||||
|
|
||||||
fieldRow: { gap: 2 },
|
fieldRow: { gap: 2 },
|
||||||
fieldValue: { ...typography.body, color: colors.text },
|
fieldValue: { ...typography.body, color: c.text },
|
||||||
fieldLabel: { ...typography.caption, color: colors.textMuted },
|
fieldLabel: { ...typography.caption, color: c.textMuted },
|
||||||
noteText: { ...typography.body, color: colors.text },
|
noteText: { ...typography.body, color: c.text },
|
||||||
|
|
||||||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: 2 },
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: 2 },
|
||||||
contextChip: {
|
contextChip: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
},
|
},
|
||||||
contextChipText: { ...typography.small, color: colors.textMuted },
|
contextChipText: { ...typography.small, color: c.textMuted },
|
||||||
tagChip: {
|
tagChip: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
tagChipText: { ...typography.caption, color: colors.primary },
|
tagChipText: { ...typography.caption, color: c.primary },
|
||||||
|
|
||||||
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
missingText: { ...typography.body, color: colors.textMuted },
|
missingText: { ...typography.body, color: c.textMuted },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import { getContactKeywords } from '../lib/contact-utils';
|
|||||||
import { getContactDisplayName } from '../lib/contact-utils';
|
import { getContactDisplayName } from '../lib/contact-utils';
|
||||||
import FieldBlock, { FieldRow } from '../components/contacts/FieldBlock';
|
import FieldBlock, { FieldRow } from '../components/contacts/FieldBlock';
|
||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
|
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactForm'>;
|
||||||
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
|
type Route = RouteProp<RootStackParamList, 'ContactForm'>;
|
||||||
@@ -250,6 +251,8 @@ function ContextPills({
|
|||||||
options: string[];
|
options: string[];
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.pillRow}>
|
<View style={styles.pillRow}>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
@@ -266,6 +269,8 @@ function ContextPills({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactFormScreen() {
|
export default function ContactFormScreen() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const route = useRoute<Route>();
|
const route = useRoute<Route>();
|
||||||
const { contactId, addressBookId: initialBook, asGroup } = route.params || {};
|
const { contactId, addressBookId: initialBook, asGroup } = route.params || {};
|
||||||
@@ -360,7 +365,7 @@ export default function ContactFormScreen() {
|
|||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={handleBack} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={handleBack} style={styles.headerBtn} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={colors.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>{headerTitle}</Text>
|
<Text style={styles.headerTitle} numberOfLines={1}>{headerTitle}</Text>
|
||||||
<Pressable onPress={handleSave} disabled={saving} style={styles.saveBtn} hitSlop={8}>
|
<Pressable onPress={handleSave} disabled={saving} style={styles.saveBtn} hitSlop={8}>
|
||||||
@@ -368,7 +373,7 @@ export default function ContactFormScreen() {
|
|||||||
<Text style={styles.saveLabel}>Saving…</Text>
|
<Text style={styles.saveLabel}>Saving…</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check size={16} color={colors.primaryForeground} />
|
<Check size={16} color={c.primaryForeground} />
|
||||||
<Text style={styles.saveLabel}>Save</Text>
|
<Text style={styles.saveLabel}>Save</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -382,7 +387,7 @@ export default function ContactFormScreen() {
|
|||||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
{/* Address book picker */}
|
{/* Address book picker */}
|
||||||
{addressBooks.length > 1 && (
|
{addressBooks.length > 1 && (
|
||||||
<FieldBlock title="Address Book" icon={<User size={14} color={colors.primary} />} category="contact">
|
<FieldBlock title="Address Book" icon={<User size={14} color={c.primary} />} category="contact">
|
||||||
<View style={styles.pillRow}>
|
<View style={styles.pillRow}>
|
||||||
{addressBooks.map((book) => (
|
{addressBooks.map((book) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -400,7 +405,7 @@ export default function ContactFormScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Photo */}
|
{/* Photo */}
|
||||||
<FieldBlock title="Photo" icon={<Camera size={14} color={colors.primary} />} category="contact">
|
<FieldBlock title="Photo" icon={<Camera size={14} color={c.primary} />} category="contact">
|
||||||
<View style={styles.photoRow}>
|
<View style={styles.photoRow}>
|
||||||
{form.photoUri ? (
|
{form.photoUri ? (
|
||||||
<View style={styles.photoThumbWrap}>
|
<View style={styles.photoThumbWrap}>
|
||||||
@@ -413,12 +418,12 @@ export default function ContactFormScreen() {
|
|||||||
style={styles.photoRemoveBtn}
|
style={styles.photoRemoveBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<XIcon size={14} color={colors.primaryForeground} />
|
<XIcon size={14} color={c.primaryForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.photoThumb, styles.photoPlaceholder]}>
|
<View style={[styles.photoThumb, styles.photoPlaceholder]}>
|
||||||
<Camera size={28} color={colors.textMuted} />
|
<Camera size={28} color={c.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -455,25 +460,25 @@ export default function ContactFormScreen() {
|
|||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<FieldBlock title="Name" icon={<User size={14} color={colors.primary} />} category="contact">
|
<FieldBlock title="Name" icon={<User size={14} color={c.primary} />} category="contact">
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="First name"
|
placeholder="First name"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={form.given}
|
value={form.given}
|
||||||
onChangeText={(v) => updateForm('given', v)}
|
onChangeText={(v) => updateForm('given', v)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Last name"
|
placeholder="Last name"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={form.surname}
|
value={form.surname}
|
||||||
onChangeText={(v) => updateForm('surname', v)}
|
onChangeText={(v) => updateForm('surname', v)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Display name (optional)"
|
placeholder="Display name (optional)"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={form.full}
|
value={form.full}
|
||||||
onChangeText={(v) => updateForm('full', v)}
|
onChangeText={(v) => updateForm('full', v)}
|
||||||
/>
|
/>
|
||||||
@@ -482,7 +487,7 @@ export default function ContactFormScreen() {
|
|||||||
{/* Emails */}
|
{/* Emails */}
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
title="Email"
|
title="Email"
|
||||||
icon={<Mail size={14} color={colors.primary} />}
|
icon={<Mail size={14} color={c.primary} />}
|
||||||
category="contact"
|
category="contact"
|
||||||
onAdd={() => updateForm('emails', [...form.emails, { address: '', label: '', context: 'personal' }])}
|
onAdd={() => updateForm('emails', [...form.emails, { address: '', label: '', context: 'personal' }])}
|
||||||
addLabel="Add"
|
addLabel="Add"
|
||||||
@@ -495,7 +500,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
value={e.address}
|
value={e.address}
|
||||||
@@ -521,7 +526,7 @@ export default function ContactFormScreen() {
|
|||||||
{/* Phones */}
|
{/* Phones */}
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
title="Phone"
|
title="Phone"
|
||||||
icon={<Phone size={14} color={colors.primary} />}
|
icon={<Phone size={14} color={c.primary} />}
|
||||||
category="contact"
|
category="contact"
|
||||||
onAdd={() => updateForm('phones', [...form.phones, { number: '', label: '', context: 'personal' }])}
|
onAdd={() => updateForm('phones', [...form.phones, { number: '', label: '', context: 'personal' }])}
|
||||||
addLabel="Add"
|
addLabel="Add"
|
||||||
@@ -534,7 +539,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="+1 555 0100"
|
placeholder="+1 555 0100"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
keyboardType="phone-pad"
|
keyboardType="phone-pad"
|
||||||
value={p.number}
|
value={p.number}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
@@ -559,7 +564,7 @@ export default function ContactFormScreen() {
|
|||||||
{/* Orgs */}
|
{/* Orgs */}
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
title="Organization"
|
title="Organization"
|
||||||
icon={<Building size={14} color={colors.calendar.orange} />}
|
icon={<Building size={14} color={c.calendar.orange} />}
|
||||||
category="work"
|
category="work"
|
||||||
onAdd={() => updateForm('orgs', [...form.orgs, { name: '', title: '' }])}
|
onAdd={() => updateForm('orgs', [...form.orgs, { name: '', title: '' }])}
|
||||||
addLabel="Add"
|
addLabel="Add"
|
||||||
@@ -572,7 +577,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Company"
|
placeholder="Company"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={o.name}
|
value={o.name}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
const next = [...form.orgs];
|
const next = [...form.orgs];
|
||||||
@@ -583,7 +588,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={o.title}
|
value={o.title}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
const next = [...form.orgs];
|
const next = [...form.orgs];
|
||||||
@@ -598,7 +603,7 @@ export default function ContactFormScreen() {
|
|||||||
{/* Addresses */}
|
{/* Addresses */}
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
title="Address"
|
title="Address"
|
||||||
icon={<MapPin size={14} color={colors.calendar.green} />}
|
icon={<MapPin size={14} color={c.calendar.green} />}
|
||||||
category="location"
|
category="location"
|
||||||
onAdd={() => updateForm('addresses', [...form.addresses, { full: '', context: 'home' }])}
|
onAdd={() => updateForm('addresses', [...form.addresses, { full: '', context: 'home' }])}
|
||||||
addLabel="Add"
|
addLabel="Add"
|
||||||
@@ -611,7 +616,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.multiline]}
|
style={[styles.input, styles.multiline]}
|
||||||
placeholder="Street, city, region, postcode, country"
|
placeholder="Street, city, region, postcode, country"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
multiline
|
multiline
|
||||||
value={a.full}
|
value={a.full}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
@@ -636,7 +641,7 @@ export default function ContactFormScreen() {
|
|||||||
{/* Anniversaries */}
|
{/* Anniversaries */}
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
title="Important Dates"
|
title="Important Dates"
|
||||||
icon={<Cake size={14} color={colors.calendar.pink} />}
|
icon={<Cake size={14} color={c.calendar.pink} />}
|
||||||
category="personal"
|
category="personal"
|
||||||
onAdd={() => updateForm('anniversaries', [...form.anniversaries, { kind: 'birth', date: '' }])}
|
onAdd={() => updateForm('anniversaries', [...form.anniversaries, { kind: 'birth', date: '' }])}
|
||||||
addLabel="Add"
|
addLabel="Add"
|
||||||
@@ -650,7 +655,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.dateTextInput]}
|
style={[styles.input, styles.dateTextInput]}
|
||||||
placeholder="YYYY-MM-DD or --MM-DD"
|
placeholder="YYYY-MM-DD or --MM-DD"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={a.date}
|
value={a.date}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
const next = [...form.anniversaries];
|
const next = [...form.anniversaries];
|
||||||
@@ -663,7 +668,7 @@ export default function ContactFormScreen() {
|
|||||||
style={styles.datePickerBtn}
|
style={styles.datePickerBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Calendar size={18} color={colors.primary} />
|
<Calendar size={18} color={c.primary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ContextPills
|
<ContextPills
|
||||||
@@ -680,7 +685,7 @@ export default function ContactFormScreen() {
|
|||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
{/* Keywords */}
|
{/* Keywords */}
|
||||||
<FieldBlock title="Tags" icon={<Tag size={14} color={colors.calendar.teal} />} category="digital">
|
<FieldBlock title="Tags" icon={<Tag size={14} color={c.calendar.teal} />} category="digital">
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow}>
|
||||||
{form.keywords.map((kw) => (
|
{form.keywords.map((kw) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -696,7 +701,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="Add tag and press Enter"
|
placeholder="Add tag and press Enter"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={keywordInput}
|
value={keywordInput}
|
||||||
onChangeText={setKeywordInput}
|
onChangeText={setKeywordInput}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
@@ -729,7 +734,7 @@ export default function ContactFormScreen() {
|
|||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
title="Notes"
|
title="Notes"
|
||||||
icon={<FileText size={14} color={colors.calendar.purple} />}
|
icon={<FileText size={14} color={c.calendar.purple} />}
|
||||||
category="notes"
|
category="notes"
|
||||||
onAdd={() => updateForm('notes', [...form.notes, { note: '' }])}
|
onAdd={() => updateForm('notes', [...form.notes, { note: '' }])}
|
||||||
addLabel="Add"
|
addLabel="Add"
|
||||||
@@ -750,7 +755,7 @@ export default function ContactFormScreen() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, styles.multiline]}
|
style={[styles.input, styles.multiline]}
|
||||||
placeholder="Notes"
|
placeholder="Notes"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
multiline
|
multiline
|
||||||
value={n.note}
|
value={n.note}
|
||||||
onChangeText={(v) => {
|
onChangeText={(v) => {
|
||||||
@@ -852,8 +857,9 @@ function formatDateAsISO(d: Date): string {
|
|||||||
return `${y}-${m}-${da}`;
|
return `${y}-${m}-${da}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
scrollContent: { paddingVertical: spacing.md, paddingBottom: spacing.xxxl * 2 },
|
scrollContent: { paddingVertical: spacing.md, paddingBottom: spacing.xxxl * 2 },
|
||||||
|
|
||||||
dateInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
dateInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||||
@@ -862,34 +868,34 @@ const styles = StyleSheet.create({
|
|||||||
width: 40, height: 40,
|
width: 40, height: 40,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
photoRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
photoRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||||
photoThumbWrap: { position: 'relative' },
|
photoThumbWrap: { position: 'relative' },
|
||||||
photoThumb: {
|
photoThumb: {
|
||||||
width: 72, height: 72,
|
width: 72, height: 72,
|
||||||
borderRadius: 36,
|
borderRadius: 36,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
photoPlaceholder: {
|
photoPlaceholder: {
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderWidth: 1, borderColor: colors.border, borderStyle: 'dashed',
|
borderWidth: 1, borderColor: c.border, borderStyle: 'dashed',
|
||||||
},
|
},
|
||||||
photoRemoveBtn: {
|
photoRemoveBtn: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -4, right: -4,
|
top: -4, right: -4,
|
||||||
width: 24, height: 24,
|
width: 24, height: 24,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: colors.error,
|
backgroundColor: c.error,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
},
|
},
|
||||||
photoPickBtn: {
|
photoPickBtn: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
photoPickLabel: { ...typography.bodyMedium, color: colors.primary },
|
photoPickLabel: { ...typography.bodyMedium, color: c.primary },
|
||||||
pickerOverlay: {
|
pickerOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
@@ -897,7 +903,7 @@ const styles = StyleSheet.create({
|
|||||||
pickerSheet: {
|
pickerSheet: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0, right: 0, bottom: 0,
|
left: 0, right: 0, bottom: 0,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderTopLeftRadius: radius.lg,
|
borderTopLeftRadius: radius.lg,
|
||||||
borderTopRightRadius: radius.lg,
|
borderTopRightRadius: radius.lg,
|
||||||
paddingBottom: spacing.lg,
|
paddingBottom: spacing.lg,
|
||||||
@@ -907,9 +913,9 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
pickerDone: { ...typography.bodySemibold, color: colors.primary },
|
pickerDone: { ...typography.bodySemibold, color: c.primary },
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -918,34 +924,34 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
width: 40, height: 40,
|
width: 40, height: 40,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
|
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||||
saveBtn: {
|
saveBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
height: 36,
|
height: 36,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
saveLabel: { ...typography.bodyMedium, color: colors.primaryForeground },
|
saveLabel: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||||
|
|
||||||
input: {
|
input: {
|
||||||
height: componentSizes.inputHeight,
|
height: componentSizes.inputHeight,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
multiline: { height: 80, paddingVertical: spacing.sm, textAlignVertical: 'top' },
|
multiline: { height: 80, paddingVertical: spacing.sm, textAlignVertical: 'top' },
|
||||||
|
|
||||||
@@ -954,13 +960,13 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
|
pillActive: { backgroundColor: c.primary, borderColor: c.primary },
|
||||||
pillText: { ...typography.caption, color: colors.textSecondary },
|
pillText: { ...typography.caption, color: c.textSecondary },
|
||||||
pillTextActive: { color: colors.primaryForeground },
|
pillTextActive: { color: c.primaryForeground },
|
||||||
|
|
||||||
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
||||||
keywordChip: {
|
keywordChip: {
|
||||||
@@ -969,28 +975,29 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
keywordChipText: { ...typography.caption, color: colors.primary },
|
keywordChipText: { ...typography.caption, color: c.primary },
|
||||||
keywordChipRemove: { ...typography.caption, color: colors.primary },
|
keywordChipRemove: { ...typography.caption, color: c.primary },
|
||||||
suggestedChip: {
|
suggestedChip: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
suggestedChipText: { ...typography.caption, color: colors.textSecondary },
|
suggestedChipText: { ...typography.caption, color: c.textSecondary },
|
||||||
|
|
||||||
addNoteBtn: {
|
addNoteBtn: {
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
addNoteText: { ...typography.caption, color: colors.textMuted },
|
addNoteText: { ...typography.caption, color: c.textMuted },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+470
-75
@@ -1,110 +1,505 @@
|
|||||||
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 { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
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 c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
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={c.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={c.error} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||||
|
<Menu size={22} color={c.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={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate('ContactForm', {})}
|
||||||
|
style={styles.addBtn}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<Plus size={18} color={c.primaryForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{searchActive && (
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<Search size={16} color={c.textMuted} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Search contacts..."
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||||
|
<X size={16} color={c.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={c.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
loading ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<ActivityIndicator color={c.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<UserCircle size={48} color={c.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({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.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: c.text, flexShrink: 1 },
|
||||||
headerCount: { fontSize: 13, color: colors.textSecondary },
|
headerCount: {
|
||||||
listContent: { paddingBottom: 20 },
|
...typography.small,
|
||||||
sectionHeader: {
|
color: c.textMuted,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.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',
|
||||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
justifyContent: 'center',
|
||||||
});
|
backgroundColor: c.primary,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
},
|
||||||
|
|
||||||
|
searchBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
height: componentSizes.inputHeight,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
searchInput: { flex: 1, ...typography.body, color: c.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: c.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: c.primary,
|
||||||
|
borderColor: c.primary,
|
||||||
|
},
|
||||||
|
chipText: { ...typography.captionMedium, color: c.textSecondary },
|
||||||
|
chipTextActive: { color: c.primaryForeground },
|
||||||
|
chipDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 16,
|
||||||
|
backgroundColor: c.border,
|
||||||
|
marginHorizontal: spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
sectionHeader: {
|
||||||
|
backgroundColor: c.background,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.borderLight,
|
||||||
|
},
|
||||||
|
sectionHeaderText: { ...typography.small, color: c.primary },
|
||||||
|
|
||||||
|
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||||
|
|
||||||
|
errorBanner: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
backgroundColor: c.errorBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.errorBorder,
|
||||||
|
},
|
||||||
|
errorText: { ...typography.caption, color: c.errorForeground },
|
||||||
|
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.xxxl * 2,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
|
emptySubtitle: { ...typography.caption, color: c.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 },
|
|
||||||
});
|
|
||||||
+223
-116
@@ -6,10 +6,15 @@ import {
|
|||||||
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
||||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
import SidebarDrawer from '../components/SidebarDrawer';
|
import SidebarDrawer from '../components/SidebarDrawer';
|
||||||
import SenderAvatar from '../components/SenderAvatar';
|
import SenderAvatar from '../components/SenderAvatar';
|
||||||
|
import { SwipeableRow } from '../components/SwipeableRow';
|
||||||
|
import { OfflineBanner } from '../components/OfflineBanner';
|
||||||
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||||
|
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
|
|
||||||
function getSenderName(email: Email): string {
|
function getSenderName(email: Email): string {
|
||||||
@@ -43,7 +48,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,16 +56,21 @@ 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;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const senderName = getSenderName(item);
|
const senderName = getSenderName(item);
|
||||||
const senderEmail = getSenderEmail(item);
|
const senderEmail = getSenderEmail(item);
|
||||||
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,16 +78,16 @@ 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 && (
|
||||||
<View style={styles.rowCheckboxWrap}>
|
<View style={styles.rowCheckboxWrap}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<SquareCheck size={16} color={colors.primary} />
|
<SquareCheck size={16} color={c.primary} />
|
||||||
) : (
|
) : (
|
||||||
<Square size={16} color={colors.textMuted} />
|
<Square size={16} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -92,10 +102,10 @@ function EmailRow({
|
|||||||
{senderName}
|
{senderName}
|
||||||
</Text>
|
</Text>
|
||||||
{starred && (
|
{starred && (
|
||||||
<Star size={componentSizes.statusIcon} color={colors.starred} fill={colors.starred} />
|
<Star size={componentSizes.statusIcon} color={c.starred} fill={c.starred} />
|
||||||
)}
|
)}
|
||||||
{item.hasAttachment && (
|
{item.hasAttachment && (
|
||||||
<Paperclip size={componentSizes.statusIcon} color={colors.textMuted} />
|
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.timeAndTag}>
|
<View style={styles.timeAndTag}>
|
||||||
@@ -117,14 +127,24 @@ function EmailRow({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function EmailRowSeparator() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
return <View style={styles.separator} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emailKeyExtractor = (item: Email) => item.id;
|
||||||
|
|
||||||
interface EmailListScreenProps {
|
interface EmailListScreenProps {
|
||||||
onEmailPress?: (email: Email) => void;
|
onEmailPress?: (email: Email) => void;
|
||||||
onComposePress?: () => void;
|
onComposePress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
|
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||||
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
||||||
const emails = useEmailStore((s) => s.emails);
|
const emails = useEmailStore((s) => s.emails);
|
||||||
@@ -145,6 +165,24 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
const markUnread = useEmailStore((s) => s.markUnread);
|
const markUnread = useEmailStore((s) => s.markUnread);
|
||||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||||
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
|
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
|
||||||
|
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
|
||||||
|
|
||||||
|
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||||
|
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||||
|
const networkOnline = useNetworkStore((s) => s.online);
|
||||||
|
|
||||||
|
const archiveMailboxId = React.useMemo(
|
||||||
|
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||||
|
[mailboxes],
|
||||||
|
);
|
||||||
|
const trashMailboxId = React.useMemo(
|
||||||
|
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
|
||||||
|
[mailboxes],
|
||||||
|
);
|
||||||
|
const junkMailboxId = React.useMemo(
|
||||||
|
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
|
||||||
|
[mailboxes],
|
||||||
|
);
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||||
@@ -152,6 +190,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 +209,68 @@ 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 handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => {
|
||||||
|
if (action === 'none') return;
|
||||||
|
const email = emailsRef.current.find((e) => e.id === id);
|
||||||
|
if (!email || !currentMailboxId) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'archive':
|
||||||
|
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||||
|
void moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
if (trashMailboxId) {
|
||||||
|
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'spam':
|
||||||
|
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
|
||||||
|
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'read':
|
||||||
|
if (isUnread(email)) void markRead(id);
|
||||||
|
else void markUnread(id);
|
||||||
|
break;
|
||||||
|
case 'star':
|
||||||
|
void toggleStar(id, !isStarred(email));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
|
||||||
|
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const renderEmailRow = React.useCallback(
|
||||||
|
({ item }: { item: Email }) => (
|
||||||
|
<SwipeableRow
|
||||||
|
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||||
|
rightAction={selectionMode ? 'none' : swipeRightAction}
|
||||||
|
context={{ unread: isUnread(item), starred: isStarred(item) }}
|
||||||
|
onAction={(action) => handleSwipeAction(item.id, action)}
|
||||||
|
>
|
||||||
|
<EmailRow
|
||||||
|
item={item}
|
||||||
|
selected={selectedIds.has(item.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onPress={handleRowPress}
|
||||||
|
onLongPress={toggleSelect}
|
||||||
|
/>
|
||||||
|
</SwipeableRow>
|
||||||
|
),
|
||||||
|
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
|
||||||
|
);
|
||||||
|
|
||||||
const clearSelection = React.useCallback(() => {
|
const clearSelection = React.useCallback(() => {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
@@ -284,7 +394,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
{selectionMode ? (
|
{selectionMode ? (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={clearSelection} style={styles.headerButton}>
|
<Pressable onPress={clearSelection} style={styles.headerButton}>
|
||||||
<X size={20} color={colors.text} />
|
<X size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text>
|
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -294,8 +404,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
size={20}
|
size={20}
|
||||||
color={allSelectedAreStarred ? colors.starred : colors.text}
|
color={allSelectedAreStarred ? c.starred : c.text}
|
||||||
fill={allSelectedAreStarred ? colors.starred : 'transparent'}
|
fill={allSelectedAreStarred ? c.starred : 'transparent'}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -304,9 +414,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
{allSelectedAreRead ? (
|
{allSelectedAreRead ? (
|
||||||
<MailIcon size={20} color={colors.text} />
|
<MailIcon size={20} color={c.text} />
|
||||||
) : (
|
) : (
|
||||||
<MailOpen size={20} color={colors.text} />
|
<MailOpen size={20} color={c.text} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -314,13 +424,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
style={styles.headerButton}
|
style={styles.headerButton}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<Trash2 size={20} color={colors.text} />
|
<Trash2 size={20} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
|
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
|
||||||
<Menu size={20} color={colors.textMuted} />
|
<Menu size={20} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
@@ -336,21 +446,21 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
<View style={styles.searchBar}>
|
<View style={styles.searchBar}>
|
||||||
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}>
|
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}>
|
||||||
{allSelected ? (
|
{allSelected ? (
|
||||||
<SquareCheck size={18} color={colors.primary} />
|
<SquareCheck size={18} color={c.primary} />
|
||||||
) : selectionMode ? (
|
) : selectionMode ? (
|
||||||
<View style={styles.checkboxIndeterminate}>
|
<View style={styles.checkboxIndeterminate}>
|
||||||
<Minus size={14} color={colors.background} />
|
<Minus size={14} color={c.background} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Square size={18} color={colors.textMuted} />
|
<Square size={18} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.searchInputArea}>
|
<View style={styles.searchInputArea}>
|
||||||
<Search size={16} color={colors.textMuted} />
|
<Search size={16} color={c.textMuted} />
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
placeholder="Search mail..."
|
placeholder="Search mail..."
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChangeText={setSearchInput}
|
onChangeText={setSearchInput}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -363,7 +473,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.searchClearButton}
|
style={styles.searchClearButton}
|
||||||
>
|
>
|
||||||
<X size={14} color={colors.textMuted} />
|
<X size={14} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -371,7 +481,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
|
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
|
||||||
onPress={() => setFilterMenuOpen(true)}
|
onPress={() => setFilterMenuOpen(true)}
|
||||||
>
|
>
|
||||||
<Filter size={18} color={activeFilterCount > 0 ? colors.primary : colors.textMuted} />
|
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<View style={styles.filterBadge}>
|
<View style={styles.filterBadge}>
|
||||||
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
|
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
|
||||||
@@ -384,7 +494,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
<View style={styles.filterChipsRow}>
|
<View style={styles.filterChipsRow}>
|
||||||
{storeSearchQuery ? (
|
{storeSearchQuery ? (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
<Search size={12} color={colors.textSecondary} />
|
<Search size={12} color={c.textSecondary} />
|
||||||
<Text style={styles.chipText} numberOfLines={1}>
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
{storeSearchQuery}
|
{storeSearchQuery}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -407,35 +517,35 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
) : null}
|
) : null}
|
||||||
{filters.dateAfter ? (
|
{filters.dateAfter ? (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
<CalendarDays size={12} color={colors.textSecondary} />
|
<CalendarDays size={12} color={c.textSecondary} />
|
||||||
<Text style={styles.chipText}>After {filters.dateAfter}</Text>
|
<Text style={styles.chipText}>After {filters.dateAfter}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{filters.dateBefore ? (
|
{filters.dateBefore ? (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
<CalendarDays size={12} color={colors.textSecondary} />
|
<CalendarDays size={12} color={c.textSecondary} />
|
||||||
<Text style={styles.chipText}>Before {filters.dateBefore}</Text>
|
<Text style={styles.chipText}>Before {filters.dateBefore}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{filters.isUnread !== undefined && (
|
{filters.isUnread !== undefined && (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
{filters.isUnread ? (
|
{filters.isUnread ? (
|
||||||
<MailIcon size={12} color={colors.textSecondary} />
|
<MailIcon size={12} color={c.textSecondary} />
|
||||||
) : (
|
) : (
|
||||||
<MailOpen size={12} color={colors.textSecondary} />
|
<MailOpen size={12} color={c.textSecondary} />
|
||||||
)}
|
)}
|
||||||
<Text style={styles.chipText}>{filters.isUnread ? 'Unread' : 'Read'}</Text>
|
<Text style={styles.chipText}>{filters.isUnread ? 'Unread' : 'Read'}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{filters.isStarred !== undefined && (
|
{filters.isStarred !== undefined && (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
<Star size={12} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />
|
<Star size={12} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />
|
||||||
<Text style={styles.chipText}>{filters.isStarred ? 'Starred' : 'Not starred'}</Text>
|
<Text style={styles.chipText}>{filters.isStarred ? 'Starred' : 'Not starred'}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{filters.hasAttachment !== undefined && (
|
{filters.hasAttachment !== undefined && (
|
||||||
<View style={styles.chip}>
|
<View style={styles.chip}>
|
||||||
<Paperclip size={12} color={colors.textSecondary} />
|
<Paperclip size={12} color={c.textSecondary} />
|
||||||
<Text style={styles.chipText}>
|
<Text style={styles.chipText}>
|
||||||
{filters.hasAttachment ? 'Has attachment' : 'No attachment'}
|
{filters.hasAttachment ? 'Has attachment' : 'No attachment'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -454,15 +564,19 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<OfflineBanner hint={emails.length > 0 ? 'Showing cached mail' : undefined} />
|
||||||
|
|
||||||
{/* Email list */}
|
{/* Email list */}
|
||||||
{loading && emails.length === 0 ? (
|
{loading && emails.length === 0 ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator color={colors.primary} />
|
<ActivityIndicator color={c.primary} />
|
||||||
<Text style={styles.loadingText}>Loading emails...</Text>
|
<Text style={styles.loadingText}>Loading emails...</Text>
|
||||||
</View>
|
</View>
|
||||||
) : error ? (
|
) : error && emails.length === 0 ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
<Text style={styles.errorText}>
|
||||||
|
{networkOnline ? error : 'No connection. Showing nothing because no mail has been cached yet.'}
|
||||||
|
</Text>
|
||||||
<Pressable onPress={() => { void refreshEmails(); }}>
|
<Pressable onPress={() => { void refreshEmails(); }}>
|
||||||
<Text style={styles.retryText}>Retry</Text>
|
<Text style={styles.retryText}>Retry</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -488,20 +602,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}
|
||||||
@@ -515,7 +618,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
onPress={onComposePress}
|
onPress={onComposePress}
|
||||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||||
>
|
>
|
||||||
<SquarePen size={24} color={colors.background} />
|
<SquarePen size={24} color={c.background} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||||
@@ -538,7 +641,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
style={styles.filterMenuHeaderBtn}
|
style={styles.filterMenuHeaderBtn}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<RotateCcw size={12} color={colors.textSecondary} />
|
<RotateCcw size={12} color={c.textSecondary} />
|
||||||
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text>
|
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -546,7 +649,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
style={styles.filterMenuClose}
|
style={styles.filterMenuClose}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<X size={16} color={colors.textSecondary} />
|
<X size={16} color={c.textSecondary} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -559,7 +662,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
value={filters.from ?? ''}
|
value={filters.from ?? ''}
|
||||||
onChangeText={(v) => setFilterField('from', v)}
|
onChangeText={(v) => setFilterField('from', v)}
|
||||||
placeholder="sender@example.com"
|
placeholder="sender@example.com"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
style={styles.filterFieldInput}
|
style={styles.filterFieldInput}
|
||||||
@@ -571,7 +674,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
value={filters.to ?? ''}
|
value={filters.to ?? ''}
|
||||||
onChangeText={(v) => setFilterField('to', v)}
|
onChangeText={(v) => setFilterField('to', v)}
|
||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
style={styles.filterFieldInput}
|
style={styles.filterFieldInput}
|
||||||
@@ -585,7 +688,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
value={filters.subject ?? ''}
|
value={filters.subject ?? ''}
|
||||||
onChangeText={(v) => setFilterField('subject', v)}
|
onChangeText={(v) => setFilterField('subject', v)}
|
||||||
placeholder="Subject contains..."
|
placeholder="Subject contains..."
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
style={styles.filterFieldInput}
|
style={styles.filterFieldInput}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -597,7 +700,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
style={styles.filterDateButton}
|
style={styles.filterDateButton}
|
||||||
onPress={() => setDatePickerField('dateAfter')}
|
onPress={() => setDatePickerField('dateAfter')}
|
||||||
>
|
>
|
||||||
<CalendarDays size={14} color={colors.textMuted} />
|
<CalendarDays size={14} color={c.textMuted} />
|
||||||
<Text style={[styles.filterDateText, !filters.dateAfter && styles.filterDateTextEmpty]}>
|
<Text style={[styles.filterDateText, !filters.dateAfter && styles.filterDateTextEmpty]}>
|
||||||
{filters.dateAfter || 'YYYY-MM-DD'}
|
{filters.dateAfter || 'YYYY-MM-DD'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -606,7 +709,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
onPress={() => setFilterField('dateAfter', undefined)}
|
onPress={() => setFilterField('dateAfter', undefined)}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<X size={14} color={colors.textMuted} />
|
<X size={14} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -617,7 +720,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
style={styles.filterDateButton}
|
style={styles.filterDateButton}
|
||||||
onPress={() => setDatePickerField('dateBefore')}
|
onPress={() => setDatePickerField('dateBefore')}
|
||||||
>
|
>
|
||||||
<CalendarDays size={14} color={colors.textMuted} />
|
<CalendarDays size={14} color={c.textMuted} />
|
||||||
<Text style={[styles.filterDateText, !filters.dateBefore && styles.filterDateTextEmpty]}>
|
<Text style={[styles.filterDateText, !filters.dateBefore && styles.filterDateTextEmpty]}>
|
||||||
{filters.dateBefore || 'YYYY-MM-DD'}
|
{filters.dateBefore || 'YYYY-MM-DD'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -626,7 +729,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
onPress={() => setFilterField('dateBefore', undefined)}
|
onPress={() => setFilterField('dateBefore', undefined)}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
>
|
>
|
||||||
<X size={14} color={colors.textMuted} />
|
<X size={14} color={c.textMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -635,13 +738,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
|
|
||||||
<View style={styles.filterToggleGroup}>
|
<View style={styles.filterToggleGroup}>
|
||||||
<TriToggle
|
<TriToggle
|
||||||
icon={<Paperclip size={14} color={colors.textSecondary} />}
|
icon={<Paperclip size={14} color={c.textSecondary} />}
|
||||||
label="Has attachment"
|
label="Has attachment"
|
||||||
value={filters.hasAttachment}
|
value={filters.hasAttachment}
|
||||||
onPress={() => cycleTriState('hasAttachment')}
|
onPress={() => cycleTriState('hasAttachment')}
|
||||||
/>
|
/>
|
||||||
<TriToggle
|
<TriToggle
|
||||||
icon={<Star size={14} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />}
|
icon={<Star size={14} color={c.starred} fill={filters.isStarred ? c.starred : 'transparent'} />}
|
||||||
label="Starred"
|
label="Starred"
|
||||||
value={filters.isStarred}
|
value={filters.isStarred}
|
||||||
onPress={() => cycleTriState('isStarred')}
|
onPress={() => cycleTriState('isStarred')}
|
||||||
@@ -649,9 +752,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
<TriToggle
|
<TriToggle
|
||||||
icon={
|
icon={
|
||||||
filters.isUnread === false ? (
|
filters.isUnread === false ? (
|
||||||
<MailOpen size={14} color={colors.textSecondary} />
|
<MailOpen size={14} color={c.textSecondary} />
|
||||||
) : (
|
) : (
|
||||||
<MailIcon size={14} color={colors.textSecondary} />
|
<MailIcon size={14} color={c.textSecondary} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
label={filters.isUnread === false ? 'Read' : 'Unread'}
|
label={filters.isUnread === false ? 'Read' : 'Unread'}
|
||||||
@@ -721,6 +824,8 @@ function TriToggle({
|
|||||||
value: boolean | undefined;
|
value: boolean | undefined;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const state =
|
const state =
|
||||||
value === true ? 'on' : value === false ? 'off' : 'unset';
|
value === true ? 'on' : value === false ? 'off' : 'unset';
|
||||||
return (
|
return (
|
||||||
@@ -746,8 +851,9 @@ function TriToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
|
|
||||||
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
|
// Header - matches web mobile-header: h-14 (56px), px-4, border-b
|
||||||
header: {
|
header: {
|
||||||
@@ -756,7 +862,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
height: componentSizes.headerHeight,
|
height: componentSizes.headerHeight,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
headerButton: {
|
headerButton: {
|
||||||
@@ -764,7 +870,7 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1 }, // text-lg font-semibold
|
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
|
||||||
headerLogo: {
|
headerLogo: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -789,13 +895,13 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
height: componentSizes.inputHeight, // h-10 = 40px
|
height: componentSizes.inputHeight, // h-10 = 40px
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
searchInput: { flex: 1, ...typography.body, color: colors.text },
|
searchInput: { flex: 1, ...typography.body, color: c.text },
|
||||||
filterButton: {
|
filterButton: {
|
||||||
width: componentSizes.buttonMd, height: componentSizes.buttonMd,
|
width: componentSizes.buttonMd, height: componentSizes.buttonMd,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -803,7 +909,7 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
filterButtonActive: {
|
filterButtonActive: {
|
||||||
backgroundColor: colors.accent,
|
backgroundColor: c.accent,
|
||||||
},
|
},
|
||||||
filterBadge: {
|
filterBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -812,7 +918,7 @@ const styles = StyleSheet.create({
|
|||||||
minWidth: 16,
|
minWidth: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
@@ -820,7 +926,7 @@ const styles = StyleSheet.create({
|
|||||||
filterBadgeText: {
|
filterBadgeText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
lineHeight: 14,
|
lineHeight: 14,
|
||||||
},
|
},
|
||||||
searchClearButton: {
|
searchClearButton: {
|
||||||
@@ -842,9 +948,9 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
@@ -852,7 +958,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
clearAllButton: {
|
clearAllButton: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
@@ -860,7 +966,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
clearAllText: {
|
clearAllText: {
|
||||||
...typography.captionMedium,
|
...typography.captionMedium,
|
||||||
color: colors.primary,
|
color: c.primary,
|
||||||
},
|
},
|
||||||
filterBackdrop: {
|
filterBackdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -873,10 +979,10 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 380,
|
maxWidth: 380,
|
||||||
maxHeight: '85%',
|
maxHeight: '85%',
|
||||||
backgroundColor: colors.popover,
|
backgroundColor: c.popover,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
filterMenuHeader: {
|
filterMenuHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -886,11 +992,11 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: spacing.md,
|
paddingTop: spacing.md,
|
||||||
paddingBottom: spacing.sm,
|
paddingBottom: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
filterMenuTitle: {
|
filterMenuTitle: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
filterMenuHeaderActions: {
|
filterMenuHeaderActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -907,7 +1013,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
filterMenuHeaderBtnText: {
|
filterMenuHeaderBtnText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
filterMenuClose: {
|
filterMenuClose: {
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -927,15 +1033,15 @@ const styles = StyleSheet.create({
|
|||||||
filterFieldHalf: { flex: 1 },
|
filterFieldHalf: { flex: 1 },
|
||||||
filterFieldLabel: {
|
filterFieldLabel: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
filterFieldInput: {
|
filterFieldInput: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
@@ -945,20 +1051,20 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
height: 32,
|
height: 32,
|
||||||
},
|
},
|
||||||
filterDateText: {
|
filterDateText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
filterDateTextEmpty: {
|
filterDateTextEmpty: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
filterToggleGroup: {
|
filterToggleGroup: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -975,26 +1081,26 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
triToggleOn: {
|
triToggleOn: {
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||||
},
|
},
|
||||||
triToggleOff: {
|
triToggleOff: {
|
||||||
backgroundColor: colors.muted,
|
backgroundColor: c.muted,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
triToggleText: {
|
triToggleText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
triToggleTextOn: {
|
triToggleTextOn: {
|
||||||
color: colors.primary,
|
color: c.primary,
|
||||||
},
|
},
|
||||||
triToggleTextOff: {
|
triToggleTextOff: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
textDecorationLine: 'line-through',
|
textDecorationLine: 'line-through',
|
||||||
},
|
},
|
||||||
pickerOverlay: {
|
pickerOverlay: {
|
||||||
@@ -1006,12 +1112,12 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: colors.popover,
|
backgroundColor: c.popover,
|
||||||
paddingBottom: spacing.xl,
|
paddingBottom: spacing.xl,
|
||||||
borderTopLeftRadius: radius.lg,
|
borderTopLeftRadius: radius.lg,
|
||||||
borderTopRightRadius: radius.lg,
|
borderTopRightRadius: radius.lg,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
},
|
},
|
||||||
pickerHeader: {
|
pickerHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -1019,11 +1125,11 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
pickerDone: {
|
pickerDone: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.primary,
|
color: c.primary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email list - matches web email-list-item
|
// Email list - matches web email-list-item
|
||||||
@@ -1034,10 +1140,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md, // py-3 = 12px (density-item-py)
|
paddingVertical: spacing.md, // py-3 = 12px (density-item-py)
|
||||||
gap: spacing.md, // gap-3 = 12px (density-item-gap)
|
gap: spacing.md, // gap-3 = 12px (density-item-gap)
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border, // border-b border-border
|
borderBottomColor: c.border, // border-b border-border
|
||||||
},
|
},
|
||||||
emailRowPressed: { backgroundColor: colors.surface },
|
emailRowPressed: { backgroundColor: c.surface },
|
||||||
emailRowSelected: { backgroundColor: colors.selection },
|
emailRowSelected: { backgroundColor: c.selection },
|
||||||
rowCheckboxWrap: {
|
rowCheckboxWrap: {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: componentSizes.avatarMd,
|
height: componentSizes.avatarMd,
|
||||||
@@ -1049,7 +1155,7 @@ const styles = StyleSheet.create({
|
|||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -1068,7 +1174,7 @@ const styles = StyleSheet.create({
|
|||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
// Sender: text-sm, font-medium (read) / font-bold (unread)
|
// Sender: text-sm, font-medium (read) / font-bold (unread)
|
||||||
emailFrom: { ...typography.bodyMedium, color: colors.textSecondary, flexShrink: 1 },
|
emailFrom: { ...typography.bodyMedium, color: c.textSecondary, flexShrink: 1 },
|
||||||
timeAndTag: {
|
timeAndTag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1079,20 +1185,20 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 3,
|
gap: 3,
|
||||||
},
|
},
|
||||||
threadBadgeText: { ...typography.caption, color: colors.textMuted },
|
threadBadgeText: { ...typography.caption, color: c.textMuted },
|
||||||
// Date: text-xs (12px), tabular-nums
|
// Date: text-xs (12px), tabular-nums
|
||||||
emailDate: { ...typography.caption, color: colors.textMuted },
|
emailDate: { ...typography.caption, color: c.textMuted },
|
||||||
subjectRow: {
|
subjectRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
// Subject: text-sm (14px), font-semibold (unread) / font-normal (read)
|
// Subject: text-sm (14px), font-semibold (unread) / font-normal (read)
|
||||||
emailSubject: { ...typography.body, color: colors.text, flex: 1 },
|
emailSubject: { ...typography.body, color: c.text, flex: 1 },
|
||||||
// Preview: text-sm, leading-relaxed, line-clamp-2
|
// Preview: text-sm, leading-relaxed, line-clamp-2
|
||||||
emailPreview: { ...typography.body, color: colors.textSecondary, lineHeight: 20, opacity: 0.8 },
|
emailPreview: { ...typography.body, color: c.textSecondary, lineHeight: 20, opacity: 0.8 },
|
||||||
// Unread state: text foreground + font-bold
|
// Unread state: text foreground + font-bold
|
||||||
textUnread: { fontWeight: '600', color: colors.text },
|
textUnread: { fontWeight: '600', color: c.text },
|
||||||
textBold: { fontWeight: '700' },
|
textBold: { fontWeight: '700' },
|
||||||
// Tag pill: text-[10px], rounded-full, px-1.5 py-0.5, gap-1
|
// Tag pill: text-[10px], rounded-full, px-1.5 py-0.5, gap-1
|
||||||
tagPill: {
|
tagPill: {
|
||||||
@@ -1119,7 +1225,7 @@ const styles = StyleSheet.create({
|
|||||||
width: componentSizes.fab, // 56px (h-14)
|
width: componentSizes.fab, // 56px (h-14)
|
||||||
height: componentSizes.fab, // 56px (w-14)
|
height: componentSizes.fab, // 56px (w-14)
|
||||||
borderRadius: radius.full, // rounded-full (circle)
|
borderRadius: radius.full, // rounded-full (circle)
|
||||||
backgroundColor: colors.text, // white - matches webmail mobile FAB
|
backgroundColor: c.text, // white - matches webmail mobile FAB
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
@@ -1140,26 +1246,27 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.error,
|
color: c.error,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
},
|
},
|
||||||
retryText: {
|
retryText: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: colors.primary,
|
color: c.primary,
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
hintText: {
|
hintText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type { EmailListScreenProps };
|
export type { EmailListScreenProps };
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
|
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 { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
|
||||||
|
|
||||||
|
export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
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={c.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 ? c.text : c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!raw && !error ? (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color={c.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: componentSizes.headerHeight,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.border,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
headerBtn: {
|
||||||
|
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||||
|
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
error: { ...typography.body, color: c.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
|
||||||
|
body: { padding: spacing.lg },
|
||||||
|
source: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
color: c.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,15 +9,19 @@ 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, Tag,
|
||||||
} 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 { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
import EmailBodyView from '../components/EmailBodyView';
|
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 { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||||
|
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||||
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';
|
||||||
|
|
||||||
@@ -58,6 +62,8 @@ function plainTextBody(email: Email): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const { emailId, subject: subjectParam } = route.params;
|
const { emailId, subject: subjectParam } = route.params;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
@@ -67,12 +73,29 @@ 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);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||||
|
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||||
|
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||||
|
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||||
|
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||||
|
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -118,6 +141,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
|
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onToggleKeyword = (token: string) => {
|
||||||
|
if (!email) return;
|
||||||
|
const next = { ...email.keywords };
|
||||||
|
if (next[token]) delete next[token];
|
||||||
|
else next[token] = true;
|
||||||
|
updateLocalKeywords(next);
|
||||||
|
void setEmailKeywords(email.id, next);
|
||||||
|
};
|
||||||
|
|
||||||
const onToggleStar = () => {
|
const onToggleStar = () => {
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
const next = { ...email.keywords };
|
const next = { ...email.keywords };
|
||||||
@@ -208,17 +240,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<View style={styles.toolbar}>
|
<View style={styles.toolbar}>
|
||||||
<Pressable onPress={() => navigation.goBack()} style={styles.toolbarBack} hitSlop={8}>
|
<Pressable onPress={() => navigation.goBack()} style={styles.toolbarBack} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={colors.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.toolbarActions}>
|
<View style={styles.toolbarActions}>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Trash2 size={18} color={colors.textSecondary} />}
|
icon={<Trash2 size={18} color={c.textSecondary} />}
|
||||||
label="Delete"
|
label="Delete"
|
||||||
onPress={onDelete}
|
onPress={onDelete}
|
||||||
/>
|
/>
|
||||||
{showArchive && (
|
{showArchive && (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
icon={<Archive size={18} color={c.textSecondary} />}
|
||||||
label="Archive"
|
label="Archive"
|
||||||
onPress={onArchive}
|
onPress={onArchive}
|
||||||
/>
|
/>
|
||||||
@@ -227,9 +259,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={
|
icon={
|
||||||
unread ? (
|
unread ? (
|
||||||
<MailOpen size={18} color={colors.textSecondary} />
|
<MailOpen size={18} color={c.textSecondary} />
|
||||||
) : (
|
) : (
|
||||||
<Mail size={18} color={colors.textSecondary} />
|
<Mail size={18} color={c.textSecondary} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
label={unread ? 'Read' : 'Unread'}
|
label={unread ? 'Read' : 'Unread'}
|
||||||
@@ -240,15 +272,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
icon={
|
icon={
|
||||||
<Star
|
<Star
|
||||||
size={18}
|
size={18}
|
||||||
color={starred ? colors.starred : colors.textSecondary}
|
color={starred ? c.starred : c.textSecondary}
|
||||||
fill={starred ? colors.starred : 'transparent'}
|
fill={starred ? c.starred : 'transparent'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Star"
|
label="Star"
|
||||||
onPress={onToggleStar}
|
onPress={onToggleStar}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<MoreVertical size={18} color={colors.textSecondary} />}
|
icon={<MoreVertical size={18} color={c.textSecondary} />}
|
||||||
label="More"
|
label="More"
|
||||||
onPress={() => setMoreMenuOpen(true)}
|
onPress={() => setMoreMenuOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -257,7 +289,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<ActivityIndicator color={colors.primary} />
|
<ActivityIndicator color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
@@ -276,8 +308,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
|
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
|
||||||
<Star
|
<Star
|
||||||
size={18}
|
size={18}
|
||||||
color={starred ? colors.starred : colors.textMuted}
|
color={starred ? c.starred : c.textMuted}
|
||||||
fill={starred ? colors.starred : 'transparent'}
|
fill={starred ? c.starred : 'transparent'}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.subjectText}>{subject}</Text>
|
<Text style={styles.subjectText}>{subject}</Text>
|
||||||
@@ -320,7 +352,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
<View style={styles.attachmentsRow}>
|
<View style={styles.attachmentsRow}>
|
||||||
{email.attachments.map((att, idx) => (
|
{email.attachments.map((att, idx) => (
|
||||||
<View key={att.blobId ?? idx} style={styles.attachmentChip}>
|
<View key={att.blobId ?? idx} style={styles.attachmentChip}>
|
||||||
<Paperclip size={14} color={colors.textMuted} />
|
<Paperclip size={14} color={c.textMuted} />
|
||||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||||
{att.name || 'attachment'}
|
{att.name || 'attachment'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -340,29 +372,31 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
{/* Bottom action bar */}
|
{/* Bottom action bar */}
|
||||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<ChevronLeft size={20} color={colors.textMuted} />}
|
icon={<ChevronLeft size={20} color={c.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={c.textSecondary} />}
|
||||||
label="Reply"
|
label="Reply"
|
||||||
onPress={() => navigateCompose('reply')}
|
onPress={() => navigateCompose('reply')}
|
||||||
/>
|
/>
|
||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<ReplyAll size={20} color={colors.textSecondary} />}
|
icon={<ReplyAll size={20} color={c.textSecondary} />}
|
||||||
label="Reply All"
|
label="Reply All"
|
||||||
onPress={() => navigateCompose('replyAll')}
|
onPress={() => navigateCompose('replyAll')}
|
||||||
/>
|
/>
|
||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<Forward size={20} color={colors.textSecondary} />}
|
icon={<Forward size={20} color={c.textSecondary} />}
|
||||||
label="Forward"
|
label="Forward"
|
||||||
onPress={() => navigateCompose('forward')}
|
onPress={() => navigateCompose('forward')}
|
||||||
/>
|
/>
|
||||||
<BottomBarButton
|
<BottomBarButton
|
||||||
icon={<ChevronRight size={20} color={colors.textMuted} />}
|
icon={<ChevronRight size={20} color={c.textMuted} />}
|
||||||
label="Next"
|
label="Next"
|
||||||
disabled
|
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
|
||||||
|
disabled={!nextEmail}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -372,15 +406,38 @@ 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}
|
||||||
|
canTag={keywordDefs.length > 0}
|
||||||
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); }}
|
||||||
|
onTag={() => { setMoreMenuOpen(false); setTagMenuOpen(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
|
||||||
@@ -390,6 +447,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
|||||||
currentMailboxId={currentMailboxId}
|
currentMailboxId={currentMailboxId}
|
||||||
onPick={onMoveToMailbox}
|
onPick={onMoveToMailbox}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TagMenuSheet
|
||||||
|
visible={tagMenuOpen}
|
||||||
|
onClose={() => setTagMenuOpen(false)}
|
||||||
|
keywords={keywordDefs}
|
||||||
|
activeKeywords={email?.keywords ?? {}}
|
||||||
|
onToggle={onToggleKeyword}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -401,21 +466,31 @@ interface MoreMenuSheetProps {
|
|||||||
canArchive: boolean;
|
canArchive: boolean;
|
||||||
canMarkUnread: boolean;
|
canMarkUnread: boolean;
|
||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
|
canTag: 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;
|
||||||
|
onTag: () => 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, canTag,
|
||||||
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
|
showSpam, isInJunk, canViewSource, canExport,
|
||||||
|
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onViewSource, onExport,
|
||||||
}: MoreMenuSheetProps) {
|
}: MoreMenuSheetProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const dragHandlers = useSheetDrag({ slideY, closedY: 400, onClose });
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -442,16 +517,20 @@ function MoreMenuSheet({
|
|||||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.sheetHandle} />
|
<View {...dragHandlers}>
|
||||||
<View style={styles.sheetHeader}>
|
<View style={styles.sheetHandleHit}>
|
||||||
<Text style={styles.sheetTitle}>More actions</Text>
|
<View style={styles.sheetHandle} />
|
||||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
</View>
|
||||||
<X size={18} color={colors.textSecondary} />
|
<View style={styles.sheetHeader}>
|
||||||
</Pressable>
|
<Text style={styles.sheetTitle}>More actions</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||||
|
<X size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{canArchive && (
|
{canArchive && (
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
icon={<Archive size={18} color={c.textSecondary} />}
|
||||||
label="Archive"
|
label="Archive"
|
||||||
onPress={onArchive}
|
onPress={onArchive}
|
||||||
/>
|
/>
|
||||||
@@ -460,9 +539,9 @@ function MoreMenuSheet({
|
|||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={
|
icon={
|
||||||
unread ? (
|
unread ? (
|
||||||
<MailOpen size={18} color={colors.textSecondary} />
|
<MailOpen size={18} color={c.textSecondary} />
|
||||||
) : (
|
) : (
|
||||||
<Mail size={18} color={colors.textSecondary} />
|
<Mail size={18} color={c.textSecondary} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
label={unread ? 'Mark as read' : 'Mark as unread'}
|
label={unread ? 'Mark as read' : 'Mark as unread'}
|
||||||
@@ -471,25 +550,124 @@ function MoreMenuSheet({
|
|||||||
)}
|
)}
|
||||||
{canMove && (
|
{canMove && (
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={<FolderInput size={18} color={colors.textSecondary} />}
|
icon={<FolderInput size={18} color={c.textSecondary} />}
|
||||||
label="Move to folder…"
|
label="Move to folder…"
|
||||||
onPress={onMove}
|
onPress={onMove}
|
||||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canTag && (
|
||||||
|
<MoreMenuItem
|
||||||
|
icon={<Tag size={18} color={c.textSecondary} />}
|
||||||
|
label="Tag…"
|
||||||
|
onPress={onTag}
|
||||||
|
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showSpam && (
|
{showSpam && (
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={
|
icon={
|
||||||
isInJunk ? (
|
isInJunk ? (
|
||||||
<ShieldCheck size={18} color={colors.success} />
|
<ShieldCheck size={18} color={c.success} />
|
||||||
) : (
|
) : (
|
||||||
<ShieldAlert size={18} color={colors.error} />
|
<ShieldAlert size={18} color={c.error} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
label={isInJunk ? 'Not spam' : 'Mark as spam'}
|
label={isInJunk ? 'Not spam' : 'Mark as spam'}
|
||||||
onPress={onToggleSpam}
|
onPress={onToggleSpam}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{canViewSource && (
|
||||||
|
<MoreMenuItem
|
||||||
|
icon={<Code size={18} color={c.textSecondary} />}
|
||||||
|
label="View source"
|
||||||
|
onPress={onViewSource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canExport && (
|
||||||
|
<MoreMenuItem
|
||||||
|
icon={<Download size={18} color={c.textSecondary} />}
|
||||||
|
label="Export email (.eml)"
|
||||||
|
onPress={onExport}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagMenuSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
keywords: KeywordDef[];
|
||||||
|
activeKeywords: Record<string, boolean>;
|
||||||
|
onToggle: (token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||||
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||||
|
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||||
|
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||||
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheet,
|
||||||
|
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View {...dragHandlers}>
|
||||||
|
<View style={styles.sheetHandleHit}>
|
||||||
|
<View style={styles.sheetHandle} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.sheetHeader}>
|
||||||
|
<Text style={styles.sheetTitle}>Tags</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||||
|
<X size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{keywords.length === 0 ? (
|
||||||
|
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||||
|
No tags yet. Add some in Settings → Keywords & Labels.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
keywords.map((kw) => {
|
||||||
|
const token = keywordToken(kw.id);
|
||||||
|
const active = !!activeKeywords[token];
|
||||||
|
const palette = c.tags[kw.color];
|
||||||
|
return (
|
||||||
|
<MoreMenuItem
|
||||||
|
key={kw.id}
|
||||||
|
icon={<View style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: palette.dot }} />}
|
||||||
|
label={kw.label}
|
||||||
|
onPress={() => onToggle(token)}
|
||||||
|
trailing={active ? <Check size={16} color={c.primary} /> : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -504,9 +682,12 @@ interface MoveMenuSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
|
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -549,12 +730,16 @@ function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }
|
|||||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.sheetHandle} />
|
<View {...dragHandlers}>
|
||||||
<View style={styles.sheetHeader}>
|
<View style={styles.sheetHandleHit}>
|
||||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
<View style={styles.sheetHandle} />
|
||||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
</View>
|
||||||
<X size={18} color={colors.textSecondary} />
|
<View style={styles.sheetHeader}>
|
||||||
</Pressable>
|
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||||
|
<X size={18} color={c.textSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
{visibleNodes.map((node) => {
|
{visibleNodes.map((node) => {
|
||||||
@@ -572,14 +757,14 @@ function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }
|
|||||||
{ paddingLeft: spacing.lg + node.depth * 16 },
|
{ paddingLeft: spacing.lg + node.depth * 16 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Icon size={16} color={canTarget ? colors.textSecondary : colors.textMuted} />
|
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
|
||||||
<Text
|
<Text
|
||||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</Text>
|
</Text>
|
||||||
{isCurrent && <Check size={14} color={colors.textMuted} />}
|
{isCurrent && <Check size={14} color={c.textMuted} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -592,6 +777,8 @@ function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }
|
|||||||
function MoreMenuItem({
|
function MoreMenuItem({
|
||||||
icon, label, onPress, trailing,
|
icon, label, onPress, trailing,
|
||||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -607,6 +794,8 @@ function MoreMenuItem({
|
|||||||
function ToolbarButton({
|
function ToolbarButton({
|
||||||
icon, label, onPress,
|
icon, label, onPress,
|
||||||
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} style={styles.toolbarAction} hitSlop={6}>
|
<Pressable onPress={onPress} style={styles.toolbarAction} hitSlop={6}>
|
||||||
{icon}
|
{icon}
|
||||||
@@ -618,6 +807,8 @@ function ToolbarButton({
|
|||||||
function BottomBarButton({
|
function BottomBarButton({
|
||||||
icon, label, onPress, disabled,
|
icon, label, onPress, disabled,
|
||||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
|
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={disabled ? undefined : onPress}
|
onPress={disabled ? undefined : onPress}
|
||||||
@@ -632,8 +823,9 @@ function BottomBarButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
|
|
||||||
// Top toolbar
|
// Top toolbar
|
||||||
toolbar: {
|
toolbar: {
|
||||||
@@ -643,9 +835,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingTop: spacing.xs,
|
paddingTop: spacing.xs,
|
||||||
paddingBottom: spacing.xs,
|
paddingBottom: spacing.xs,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
toolbarBack: {
|
toolbarBack: {
|
||||||
width: componentSizes.avatarSm,
|
width: componentSizes.avatarSm,
|
||||||
@@ -668,7 +860,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
toolbarActionLabel: {
|
toolbarActionLabel: {
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
@@ -678,14 +870,14 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
},
|
},
|
||||||
errorText: { ...typography.body, color: colors.error, textAlign: 'center' },
|
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||||
scroll: { flex: 1, backgroundColor: colors.background },
|
scroll: { flex: 1, backgroundColor: c.background },
|
||||||
|
|
||||||
// Subject block
|
// Subject block
|
||||||
subjectBlock: {
|
subjectBlock: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
},
|
},
|
||||||
@@ -708,7 +900,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
lineHeight: 28,
|
lineHeight: 28,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
letterSpacing: -0.2,
|
letterSpacing: -0.2,
|
||||||
},
|
},
|
||||||
subjectMeta: {
|
subjectMeta: {
|
||||||
@@ -716,19 +908,19 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
subjectDate: {
|
subjectDate: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
subjectSize: {
|
subjectSize: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sender block
|
// Sender block
|
||||||
senderBlock: {
|
senderBlock: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
},
|
},
|
||||||
@@ -740,27 +932,27 @@ const styles = StyleSheet.create({
|
|||||||
senderInfo: { flex: 1, minWidth: 0 },
|
senderInfo: { flex: 1, minWidth: 0 },
|
||||||
senderName: {
|
senderName: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
senderEmail: {
|
senderEmail: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
senderRecipients: {
|
senderRecipients: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
senderRecipientsLabel: {
|
senderRecipientsLabel: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
attachmentsBlock: {
|
attachmentsBlock: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
@@ -775,24 +967,24 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
borderRadius: radius.sm,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
attachmentName: {
|
attachmentName: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
maxWidth: 180,
|
maxWidth: 180,
|
||||||
},
|
},
|
||||||
attachmentSize: {
|
attachmentSize: {
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Body
|
// Body
|
||||||
bodyBlock: {
|
bodyBlock: {
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bottom bar
|
// Bottom bar
|
||||||
@@ -803,9 +995,9 @@ const styles = StyleSheet.create({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'stretch',
|
alignItems: 'stretch',
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.border,
|
borderTopColor: c.border,
|
||||||
},
|
},
|
||||||
bottomBarBtn: {
|
bottomBarBtn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -820,10 +1012,10 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
bottomBarLabel: {
|
bottomBarLabel: {
|
||||||
...typography.small,
|
...typography.small,
|
||||||
color: colors.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
bottomBarLabelDisabled: {
|
bottomBarLabelDisabled: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bottom sheet (More menu / Move folder picker)
|
// Bottom sheet (More menu / Move folder picker)
|
||||||
@@ -837,23 +1029,26 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: colors.popover,
|
backgroundColor: c.popover,
|
||||||
borderTopLeftRadius: radius.lg,
|
borderTopLeftRadius: radius.lg,
|
||||||
borderTopRightRadius: radius.lg,
|
borderTopRightRadius: radius.lg,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: c.border,
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.sm,
|
||||||
},
|
},
|
||||||
sheetTall: {
|
sheetTall: {
|
||||||
maxHeight: '75%',
|
maxHeight: '75%',
|
||||||
},
|
},
|
||||||
|
sheetHandleHit: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: spacing.xs,
|
||||||
|
paddingBottom: spacing.sm,
|
||||||
|
},
|
||||||
sheetHandle: {
|
sheetHandle: {
|
||||||
alignSelf: 'center',
|
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: colors.border,
|
backgroundColor: c.border,
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
},
|
||||||
sheetHeader: {
|
sheetHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -862,11 +1057,11 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.sm,
|
paddingBottom: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
sheetTitle: {
|
sheetTitle: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
sheetClose: {
|
sheetClose: {
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -885,7 +1080,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
},
|
},
|
||||||
moreItemPressed: { backgroundColor: colors.surfaceHover },
|
moreItemPressed: { backgroundColor: c.surfaceHover },
|
||||||
moreItemIcon: {
|
moreItemIcon: {
|
||||||
width: 20,
|
width: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -893,7 +1088,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
moreItemLabel: {
|
moreItemLabel: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -906,13 +1101,14 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm + 2,
|
paddingVertical: spacing.sm + 2,
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
},
|
},
|
||||||
moveRowPressed: { backgroundColor: colors.surfaceHover },
|
moveRowPressed: { backgroundColor: c.surfaceHover },
|
||||||
moveRowLabel: {
|
moveRowLabel: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
moveRowLabelDisabled: {
|
moveRowLabelDisabled: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+22
-17
@@ -10,7 +10,8 @@ import {
|
|||||||
import { FileText, Folder, HardDrive } from 'lucide-react-native';
|
import { FileText, Folder, HardDrive } from 'lucide-react-native';
|
||||||
import { getFileNodes } from '../api/files';
|
import { getFileNodes } from '../api/files';
|
||||||
import type { FileNode } from '../api/types';
|
import type { FileNode } from '../api/types';
|
||||||
import { colors, spacing, typography } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
function formatFileSize(bytes?: number): string {
|
function formatFileSize(bytes?: number): string {
|
||||||
if (bytes == null) return '';
|
if (bytes == null) return '';
|
||||||
@@ -20,6 +21,8 @@ function formatFileSize(bytes?: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FilesScreen() {
|
export default function FilesScreen() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const [files, setFiles] = useState<FileNode[]>([]);
|
const [files, setFiles] = useState<FileNode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -44,7 +47,7 @@ export default function FilesScreen() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<ActivityIndicator color={colors.primary} />
|
<ActivityIndicator color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,7 @@ export default function FilesScreen() {
|
|||||||
<Text style={styles.title}>Files</Text>
|
<Text style={styles.title}>Files</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<HardDrive size={48} color={colors.textMuted} />
|
<HardDrive size={48} color={c.textMuted} />
|
||||||
<Text style={styles.emptyText}>No files yet</Text>
|
<Text style={styles.emptyText}>No files yet</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -85,9 +88,9 @@ export default function FilesScreen() {
|
|||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.fileRow}>
|
<View style={styles.fileRow}>
|
||||||
{item.type === 'directory' ? (
|
{item.type === 'directory' ? (
|
||||||
<Folder size={20} color={colors.primary} />
|
<Folder size={20} color={c.primary} />
|
||||||
) : (
|
) : (
|
||||||
<FileText size={20} color={colors.textMuted} />
|
<FileText size={20} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
<View style={styles.fileInfo}>
|
<View style={styles.fileInfo}>
|
||||||
<Text style={styles.fileName} numberOfLines={1}>
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
@@ -104,22 +107,23 @@ export default function FilesScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingBottom: spacing.sm,
|
paddingBottom: spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: typography.h2.fontSize,
|
fontSize: typography.h2.fontSize,
|
||||||
fontWeight: '700' as const,
|
fontWeight: '700' as const,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
center: {
|
center: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -128,12 +132,12 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
fontSize: typography.body.fontSize,
|
fontSize: typography.body.fontSize,
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
color: colors.error,
|
color: c.error,
|
||||||
fontSize: typography.body.fontSize,
|
fontSize: typography.body.fontSize,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
@@ -142,11 +146,11 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: c.primary,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
retryText: {
|
retryText: {
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
fontWeight: '600' as const,
|
fontWeight: '600' as const,
|
||||||
},
|
},
|
||||||
fileRow: {
|
fileRow: {
|
||||||
@@ -155,7 +159,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.border,
|
borderBottomColor: c.border,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
fileInfo: {
|
fileInfo: {
|
||||||
@@ -163,11 +167,12 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
fileName: {
|
fileName: {
|
||||||
fontSize: typography.body.fontSize,
|
fontSize: typography.body.fontSize,
|
||||||
color: colors.text,
|
color: c.text,
|
||||||
},
|
},
|
||||||
fileSize: {
|
fileSize: {
|
||||||
fontSize: typography.caption.fontSize,
|
fontSize: typography.caption.fontSize,
|
||||||
color: colors.textMuted,
|
color: c.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ import type { EmailAddress } from '../api/types';
|
|||||||
import ContactListRow from '../components/contacts/ContactListRow';
|
import ContactListRow from '../components/contacts/ContactListRow';
|
||||||
import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
|
import ContactPickerSheet from '../components/contacts/ContactPickerSheet';
|
||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'GroupDetail'>;
|
type Nav = NativeStackNavigationProp<RootStackParamList, 'GroupDetail'>;
|
||||||
type Route = RouteProp<RootStackParamList, 'GroupDetail'>;
|
type Route = RouteProp<RootStackParamList, 'GroupDetail'>;
|
||||||
|
|
||||||
export default function GroupDetailScreen() {
|
export default function GroupDetailScreen() {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const route = useRoute<Route>();
|
const route = useRoute<Route>();
|
||||||
const { groupId } = route.params;
|
const { groupId } = route.params;
|
||||||
@@ -53,7 +56,7 @@ export default function GroupDetailScreen() {
|
|||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={colors.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle}>Group</Text>
|
<Text style={styles.headerTitle}>Group</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -127,7 +130,7 @@ export default function GroupDetailScreen() {
|
|||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||||
<ArrowLeft size={22} color={colors.text} />
|
<ArrowLeft size={22} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
@@ -136,17 +139,17 @@ export default function GroupDetailScreen() {
|
|||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Edit2 size={18} color={colors.text} />
|
<Edit2 size={18} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => setConfirmDelete(true)} style={styles.headerBtn} hitSlop={8}>
|
<Pressable onPress={() => setConfirmDelete(true)} style={styles.headerBtn} hitSlop={8}>
|
||||||
<Trash2 size={18} color={colors.error} />
|
<Trash2 size={18} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.hero}>
|
<View style={styles.hero}>
|
||||||
<View style={styles.heroIcon}>
|
<View style={styles.heroIcon}>
|
||||||
<Users size={32} color={colors.primary} />
|
<Users size={32} color={c.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.heroName}>{name}</Text>
|
<Text style={styles.heroName}>{name}</Text>
|
||||||
<Text style={styles.heroSubtitle}>
|
<Text style={styles.heroSubtitle}>
|
||||||
@@ -156,11 +159,11 @@ export default function GroupDetailScreen() {
|
|||||||
|
|
||||||
<View style={styles.actionsRow}>
|
<View style={styles.actionsRow}>
|
||||||
<Pressable onPress={emailAll} style={styles.actionBtn}>
|
<Pressable onPress={emailAll} style={styles.actionBtn}>
|
||||||
<Mail size={16} color={colors.primary} />
|
<Mail size={16} color={c.primary} />
|
||||||
<Text style={styles.actionLabel}>Email all</Text>
|
<Text style={styles.actionLabel}>Email all</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => setPickerOpen(true)} style={[styles.actionBtn, styles.actionBtnPrimary]}>
|
<Pressable onPress={() => setPickerOpen(true)} style={[styles.actionBtn, styles.actionBtnPrimary]}>
|
||||||
<Plus size={16} color={colors.primaryForeground} />
|
<Plus size={16} color={c.primaryForeground} />
|
||||||
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>Add member</Text>
|
<Text style={[styles.actionLabel, styles.actionLabelPrimary]}>Add member</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -181,14 +184,14 @@ export default function GroupDetailScreen() {
|
|||||||
style={styles.removeMemberBtn}
|
style={styles.removeMemberBtn}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<UserMinus size={16} color={colors.error} />
|
<UserMinus size={16} color={c.error} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Users size={40} color={colors.surfaceActive} />
|
<Users size={40} color={c.surfaceActive} />
|
||||||
<Text style={styles.emptyTitle}>No members yet</Text>
|
<Text style={styles.emptyTitle}>No members yet</Text>
|
||||||
<Text style={styles.emptySubtitle}>Tap "Add member" to get started</Text>
|
<Text style={styles.emptySubtitle}>Tap "Add member" to get started</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -231,8 +234,9 @@ export default function GroupDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -240,14 +244,14 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.borderLight,
|
borderBottomColor: c.borderLight,
|
||||||
},
|
},
|
||||||
headerBtn: {
|
headerBtn: {
|
||||||
width: 40, height: 40,
|
width: 40, height: 40,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
|
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
|
||||||
headerActions: { flexDirection: 'row', gap: spacing.xs },
|
headerActions: { flexDirection: 'row', gap: spacing.xs },
|
||||||
|
|
||||||
hero: {
|
hero: {
|
||||||
@@ -258,12 +262,12 @@ const styles = StyleSheet.create({
|
|||||||
heroIcon: {
|
heroIcon: {
|
||||||
width: 80, height: 80,
|
width: 80, height: 80,
|
||||||
borderRadius: 40,
|
borderRadius: 40,
|
||||||
backgroundColor: colors.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
heroName: { ...typography.h2, color: colors.text, marginTop: spacing.sm },
|
heroName: { ...typography.h2, color: c.text, marginTop: spacing.sm },
|
||||||
heroSubtitle: { ...typography.body, color: colors.textSecondary },
|
heroSubtitle: { ...typography.body, color: c.textSecondary },
|
||||||
|
|
||||||
actionsRow: {
|
actionsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -280,11 +284,11 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
actionBtnPrimary: { backgroundColor: colors.primary },
|
actionBtnPrimary: { backgroundColor: c.primary },
|
||||||
actionLabel: { ...typography.bodyMedium, color: colors.text },
|
actionLabel: { ...typography.bodyMedium, color: c.text },
|
||||||
actionLabelPrimary: { color: colors.primaryForeground },
|
actionLabelPrimary: { color: c.primaryForeground },
|
||||||
|
|
||||||
memberRow: {
|
memberRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -296,12 +300,13 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||||
|
|
||||||
empty: { alignItems: 'center', paddingVertical: spacing.xxxl, gap: spacing.sm },
|
empty: { alignItems: 'center', paddingVertical: spacing.xxxl, gap: spacing.sm },
|
||||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
emptySubtitle: { ...typography.caption, color: c.textMuted },
|
||||||
|
|
||||||
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
missing: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
missingText: { ...typography.body, color: colors.textMuted },
|
missingText: { ...typography.body, color: c.textMuted },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+19
-14
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Eye, EyeOff, X } from 'lucide-react-native';
|
import { Eye, EyeOff, X } from 'lucide-react-native';
|
||||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
import { Button, Input } from '../components';
|
import { Button, Input } from '../components';
|
||||||
import { useAuthStore } from '../stores/auth-store';
|
import { useAuthStore } from '../stores/auth-store';
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ interface LoginScreenProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
|
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const isLoading = useAuthStore((state) => state.isLoading);
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
const error = useAuthStore((state) => state.error);
|
const error = useAuthStore((state) => state.error);
|
||||||
@@ -56,7 +59,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
|||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
{isAddMode && onCancel ? (
|
{isAddMode && onCancel ? (
|
||||||
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
|
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
|
||||||
<X size={24} color={colors.text} />
|
<X size={24} color={c.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -113,9 +116,9 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
|||||||
rightIcon={
|
rightIcon={
|
||||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeOff size={20} color={colors.textMuted} />
|
<EyeOff size={20} color={c.textMuted} />
|
||||||
) : (
|
) : (
|
||||||
<Eye size={20} color={colors.textMuted} />
|
<Eye size={20} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
}
|
}
|
||||||
@@ -162,8 +165,9 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 50,
|
top: 50,
|
||||||
@@ -189,8 +193,8 @@ const styles = StyleSheet.create({
|
|||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
},
|
},
|
||||||
appName: { ...typography.h1, color: colors.text },
|
appName: { ...typography.h1, color: c.text },
|
||||||
tagline: { ...typography.caption, color: colors.textMuted, marginTop: spacing.xs },
|
tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs },
|
||||||
|
|
||||||
form: { gap: spacing.lg },
|
form: { gap: spacing.lg },
|
||||||
|
|
||||||
@@ -199,7 +203,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: colors.error,
|
color: c.error,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -209,12 +213,13 @@ const styles = StyleSheet.create({
|
|||||||
marginVertical: spacing.md,
|
marginVertical: spacing.md,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
dividerLine: { flex: 1, height: 1, backgroundColor: colors.border },
|
dividerLine: { flex: 1, height: 1, backgroundColor: c.border },
|
||||||
dividerText: { ...typography.caption, color: colors.textMuted },
|
dividerText: { ...typography.caption, color: c.textMuted },
|
||||||
|
|
||||||
oauthRow: { gap: spacing.md },
|
oauthRow: { gap: spacing.md },
|
||||||
|
|
||||||
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
|
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
|
||||||
footerText: { ...typography.caption, color: colors.textMuted },
|
footerText: { ...typography.caption, color: c.textMuted },
|
||||||
footerLink: { ...typography.caption, color: colors.textLink },
|
footerLink: { ...typography.caption, color: c.textLink },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+358
-128
@@ -1,143 +1,373 @@
|
|||||||
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 { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
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';
|
||||||
|
import { AppearanceSettings } from '../components/settings/AppearanceSettings';
|
||||||
|
import { CalendarSettings } from '../components/settings/CalendarSettings';
|
||||||
|
import { ContactsSettings } from '../components/settings/ContactsSettings';
|
||||||
|
import { FilesSettings } from '../components/settings/FilesSettings';
|
||||||
|
import { FilterSettings } from '../components/settings/FilterSettings';
|
||||||
|
import { TemplateSettings } from '../components/settings/TemplateSettings';
|
||||||
|
import { KeywordSettings } from '../components/settings/KeywordSettings';
|
||||||
|
import { AccountSecuritySettings } from '../components/settings/AccountSecuritySettings';
|
||||||
|
import { SmimeSettings } from '../components/settings/SmimeSettings';
|
||||||
|
import { SidebarAppsSettings } from '../components/settings/SidebarAppsSettings';
|
||||||
|
import { ThemesSettings } from '../components/settings/ThemesSettings';
|
||||||
|
import { PluginsSettings } from '../components/settings/PluginsSettings';
|
||||||
|
import { ContentSendersSettings } from '../components/settings/ContentSendersSettings';
|
||||||
|
import { LanguageSettings } from '../components/settings/LanguageSettings';
|
||||||
|
import { ComposingSettings } from '../components/settings/ComposingSettings';
|
||||||
|
import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||||
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
|
||||||
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: true },
|
||||||
|
{ id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true },
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: true },
|
||||||
|
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: true },
|
||||||
|
|
||||||
|
// Mail
|
||||||
|
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
|
||||||
|
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: true },
|
||||||
|
{ 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: true },
|
||||||
|
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true },
|
||||||
|
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
|
||||||
|
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
|
||||||
|
|
||||||
|
// Privacy & Security
|
||||||
|
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
|
||||||
|
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: true },
|
||||||
|
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true },
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: true },
|
||||||
|
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: true },
|
||||||
|
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: true },
|
||||||
|
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: true },
|
||||||
|
|
||||||
|
// Advanced
|
||||||
|
{ id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true },
|
||||||
|
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: true },
|
||||||
|
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: true },
|
||||||
|
{ 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,
|
||||||
|
language: LanguageSettings,
|
||||||
|
notifications: NotificationSettings,
|
||||||
|
appearance: AppearanceSettings,
|
||||||
|
layout: LayoutSettings,
|
||||||
|
reading: ReadingSettings,
|
||||||
|
composing: ComposingSettings,
|
||||||
|
identities: IdentitySettings,
|
||||||
|
vacation: VacationSettings,
|
||||||
|
filters: FilterSettings,
|
||||||
|
templates: TemplateSettings,
|
||||||
|
folders: FolderSettings,
|
||||||
|
keywords: KeywordSettings,
|
||||||
|
security: AccountSecuritySettings,
|
||||||
|
encryption: SmimeSettings,
|
||||||
|
content_senders: ContentSendersSettings,
|
||||||
|
calendar: CalendarSettings,
|
||||||
|
contacts: ContactsSettings,
|
||||||
|
files: FilesSettings,
|
||||||
|
sidebar_apps: SidebarAppsSettings,
|
||||||
|
about_data: AboutDataSettings,
|
||||||
|
themes: ThemesSettings,
|
||||||
|
plugins: PluginsSettings,
|
||||||
|
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 c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||||
|
// Subscribe to locale so labels re-render when the user picks a different language.
|
||||||
|
const locale = useLocaleStore((s) => s.locale);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const groupedTabs = React.useMemo(() => {
|
||||||
|
void locale; // dependency: re-translate on locale change
|
||||||
|
return groupTabs().map((g) => ({
|
||||||
|
...g,
|
||||||
|
label: t(`settings.tab_groups.${g.group}`, g.label),
|
||||||
|
items: g.items.map((tab) => ({
|
||||||
|
...tab,
|
||||||
|
label: t(`settings.tabs.${tab.id}`, tab.label),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}, [locale, t]);
|
||||||
|
|
||||||
|
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((tab) => tab.id === selectedTab)!;
|
||||||
|
const Component = TAB_COMPONENTS[selectedTab];
|
||||||
|
const TabIcon = tabDef.icon;
|
||||||
|
const tabLabel = t(`settings.tabs.${tabDef.id}`, tabDef.label);
|
||||||
|
|
||||||
|
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={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<TabIcon size={20} color={c.mutedForeground} />
|
||||||
|
<Text style={styles.headerTitle}>{tabLabel}</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
|
||||||
<Text style={styles.headerTitle}>Settings</Text>
|
onPress={onBack}
|
||||||
|
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Settings size={20} color={c.mutedForeground} />
|
||||||
|
<Text style={styles.headerTitle}>{t('settings.title', '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={c.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={c.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={c.error} />
|
||||||
|
<Text style={styles.logoutText}>{t('sidebar.sign_out', '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({
|
function makeStyles(c: ThemePalette) {
|
||||||
container: { flex: 1, backgroundColor: colors.background },
|
return StyleSheet.create({
|
||||||
header: {
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
flexDirection: 'row',
|
header: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 16,
|
alignItems: 'center',
|
||||||
paddingVertical: 12,
|
height: componentSizes.headerHeight,
|
||||||
borderBottomWidth: 1,
|
paddingHorizontal: spacing.lg,
|
||||||
borderBottomColor: colors.border,
|
borderBottomWidth: 1,
|
||||||
gap: 8,
|
borderBottomColor: c.border,
|
||||||
},
|
gap: spacing.sm,
|
||||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
},
|
||||||
profileCard: {
|
headerBackBtn: {
|
||||||
flexDirection: 'row',
|
width: 40, height: 40,
|
||||||
alignItems: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 16,
|
borderRadius: radius.md,
|
||||||
margin: 16,
|
},
|
||||||
backgroundColor: colors.surface,
|
headerBackBtnPressed: { backgroundColor: c.accent },
|
||||||
borderRadius: 12,
|
headerTitle: { ...typography.h3, color: c.text },
|
||||||
},
|
|
||||||
profileAvatar: {
|
scrollArea: { flex: 1 },
|
||||||
width: 56,
|
scrollContent: { paddingBottom: 40 },
|
||||||
height: 56,
|
detailContent: { padding: spacing.lg, paddingBottom: 40 },
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: colors.primary,
|
tabList: { paddingVertical: spacing.sm },
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
groupDivider: {
|
||||||
marginRight: 16,
|
height: 1, backgroundColor: c.border,
|
||||||
},
|
marginHorizontal: 20, marginVertical: spacing.sm,
|
||||||
profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse },
|
},
|
||||||
profileInfo: { flex: 1 },
|
groupHeader: { paddingHorizontal: 20, paddingTop: spacing.md, paddingBottom: 6 },
|
||||||
profileName: { fontSize: 18, fontWeight: '600', color: colors.text },
|
groupLabel: {
|
||||||
profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 },
|
fontSize: 11, fontWeight: '600',
|
||||||
section: { marginTop: 8 },
|
textTransform: 'uppercase', letterSpacing: 0.8,
|
||||||
sectionTitle: {
|
color: c.mutedForeground,
|
||||||
fontSize: 13,
|
},
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.textSecondary,
|
tabItem: {
|
||||||
paddingHorizontal: 16,
|
flexDirection: 'row',
|
||||||
paddingVertical: 8,
|
alignItems: 'center',
|
||||||
textTransform: 'uppercase',
|
justifyContent: 'space-between',
|
||||||
letterSpacing: 0.5,
|
paddingHorizontal: 20, paddingVertical: 14,
|
||||||
},
|
},
|
||||||
settingRow: {
|
tabItemPressed: { backgroundColor: c.muted },
|
||||||
flexDirection: 'row',
|
tabItemLeft: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row', alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
gap: spacing.md, flex: 1,
|
||||||
paddingVertical: 14,
|
},
|
||||||
},
|
tabItemLabel: { ...typography.body, color: c.text },
|
||||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
|
||||||
settingIcon: { marginRight: 14 },
|
experimentalBadge: {
|
||||||
settingContent: { flex: 1 },
|
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||||
settingTitle: { fontSize: 16, color: colors.text },
|
borderRadius: radius.full,
|
||||||
settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
paddingHorizontal: 6, paddingVertical: 2,
|
||||||
footer: {
|
},
|
||||||
alignItems: 'center',
|
experimentalText: { fontSize: 10, fontWeight: '500', color: c.warning },
|
||||||
paddingVertical: 32,
|
|
||||||
gap: 4,
|
tabItemDisabled: { opacity: 0.55 },
|
||||||
},
|
tabItemLabelDisabled: { color: c.mutedForeground },
|
||||||
footerText: { fontSize: 12, color: colors.textMuted },
|
notWorkingBadge: {
|
||||||
});
|
backgroundColor: c.muted,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
paddingHorizontal: 8, paddingVertical: 2,
|
||||||
|
},
|
||||||
|
notWorkingText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||||
|
|
||||||
|
logoutSection: {
|
||||||
|
borderTopWidth: 1, borderTopColor: c.border,
|
||||||
|
paddingHorizontal: 20, paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
logoutBtn: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: spacing.md,
|
||||||
|
paddingVertical: 10, paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
logoutBtnPressed: { backgroundColor: c.muted },
|
||||||
|
logoutText: { ...typography.body, color: c.error },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user