mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c590beb3 | ||
|
|
e5bbfeb26e | ||
|
|
373c66f2ba | ||
|
|
07a18595aa | ||
|
|
2309845274 | ||
|
|
04815c4e60 | ||
|
|
fbfb887e0a | ||
|
|
10db4c503a | ||
|
|
abbda7a74d | ||
|
|
fe7d9bdf09 | ||
|
|
a8a0d475db |
@@ -112,7 +112,7 @@ jobs:
|
||||
if [ -n "$TAG" ]; then
|
||||
echo "Will attach APK to release: $TAG"
|
||||
else
|
||||
echo "No release tag resolved — APK is in workflow artifacts only."
|
||||
echo "No release tag resolved - APK is in workflow artifacts only."
|
||||
fi
|
||||
|
||||
- name: Attach APK to release
|
||||
|
||||
@@ -22,12 +22,12 @@ import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
import LoginScreen from './src/screens/LoginScreen';
|
||||
import EmailListScreen from './src/screens/EmailListScreen';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreenNew';
|
||||
import ContactsScreen from './src/screens/ContactsScreenNew';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsScreen from './src/screens/ContactsScreen';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreenNew2';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
@@ -190,7 +190,7 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background — the FirebaseMessagingService also posts the system
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = addMessageListener((payload) => {
|
||||
|
||||
+60
-53
@@ -1,4 +1,4 @@
|
||||
# React Native Calendar — Parity Plan
|
||||
# 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
|
||||
@@ -7,52 +7,55 @@ 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,
|
||||
- `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`,
|
||||
- `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`,
|
||||
- `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.
|
||||
- `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)
|
||||
### Phase 1 - Data layer (foundation for everything else)
|
||||
|
||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
||||
|
||||
**`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
|
||||
- `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
|
||||
- `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`
|
||||
- `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.
|
||||
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
|
||||
**`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 }`
|
||||
- `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`
|
||||
- `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
|
||||
**`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
|
||||
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
|
||||
### Phase 2 - Views
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
||||
|
||||
**`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.
|
||||
@@ -60,12 +63,13 @@ lines. Drives correct rendering of recurring events across the visible window.
|
||||
- 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
|
||||
~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
|
||||
**`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
|
||||
@@ -73,35 +77,37 @@ modal with that date.
|
||||
- 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
|
||||
- _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
|
||||
**`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
|
||||
### Phase 3 - Event interaction
|
||||
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) — bottom
|
||||
**`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):
|
||||
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.
|
||||
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
|
||||
**`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).
|
||||
- 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.
|
||||
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
|
||||
@@ -109,25 +115,26 @@ modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed)
|
||||
- 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) —
|
||||
**`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
|
||||
### Phase 4 - Multi-calendar
|
||||
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) —
|
||||
**`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
|
||||
**`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
|
||||
### 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).
|
||||
@@ -141,28 +148,28 @@ add any missing shades.
|
||||
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
|
||||
`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`) —
|
||||
- **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
|
||||
- **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
|
||||
- **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-
|
||||
- **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
|
||||
- **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
|
||||
- **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-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
|
||||
- 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)
|
||||
@@ -207,14 +214,14 @@ repos/react-native/src/
|
||||
|
||||
## 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.
|
||||
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
|
||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
||||
we stop at Phase 2.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
|
||||
# Bulwark Mobile
|
||||
|
||||
> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet.
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
- Multiple accounts
|
||||
- Email list, threads, compose
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via FCM relay
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
@@ -166,7 +166,7 @@ dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
// Firebase Cloud Messaging — delivers pushes via Google Play services.
|
||||
// Firebase Cloud Messaging - delivers pushes via Google Play services.
|
||||
// Requires android/app/google-services.json at build time.
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
@@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
val data = message.data
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground — HeadlessJsTaskContext throws if started
|
||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
|
||||
Generated
+13
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.4",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
@@ -17,6 +18,7 @@
|
||||
"expo": "~54.0.33",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
@@ -5234,6 +5236,15 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-intent-launcher": {
|
||||
"version": "13.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz",
|
||||
"integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,7 @@
|
||||
"expo": "~54.0.33",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { generateAccountId } from '../lib/account-utils';
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only — no "@" or "/".
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
|
||||
function credentialsKey(accountId: string): string {
|
||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export class JMAPClient {
|
||||
|
||||
get accountId(): string {
|
||||
if (!this._accountId) {
|
||||
throw new Error('Not authenticated — call connect() first');
|
||||
throw new Error('Not authenticated - call connect() first');
|
||||
}
|
||||
return this._accountId;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// Legacy single-slot restore — kept for backward-compat tests. New code
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
@@ -294,7 +294,7 @@ export class JMAPClient {
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
if (!this.session?.downloadUrl) {
|
||||
throw new Error('Download URL not available — not connected');
|
||||
throw new Error('Download URL not available - not connected');
|
||||
}
|
||||
return this.session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ export async function createPushSubscription(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code back to the server — the call that flips the
|
||||
* Send the verification code back to the server - the call that flips the
|
||||
* subscription from pending to active (RFC 8620 §7.2.2).
|
||||
*/
|
||||
export async function verifyPushSubscription(
|
||||
|
||||
@@ -521,7 +521,7 @@ const styles = StyleSheet.create({
|
||||
accountName: { ...typography.bodyMedium, color: colors.text },
|
||||
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
|
||||
|
||||
// Account menu — floating popover card under the header
|
||||
// Account menu - floating popover card under the header
|
||||
accountMenu: {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
|
||||
@@ -56,7 +56,7 @@ export function AgendaView({
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) — mirrors the comparator the webmail uses for
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
|
||||
@@ -32,7 +32,7 @@ interface MonthViewProps {
|
||||
onLongPressDate?: (date: Date) => void;
|
||||
}
|
||||
|
||||
export function MonthView({
|
||||
function MonthViewInner({
|
||||
currentDate,
|
||||
selectedDate,
|
||||
events,
|
||||
@@ -118,6 +118,8 @@ export function MonthView({
|
||||
);
|
||||
}
|
||||
|
||||
export const MonthView = React.memo(MonthViewInner);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
grid: { paddingHorizontal: spacing.sm },
|
||||
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
|
||||
|
||||
@@ -39,7 +39,7 @@ interface WeekViewProps {
|
||||
weekStartsOn?: 0 | 1;
|
||||
}
|
||||
|
||||
export function WeekView({
|
||||
function WeekViewInner({
|
||||
selectedDate,
|
||||
events,
|
||||
calendars,
|
||||
@@ -84,7 +84,7 @@ export function WeekView({
|
||||
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
|
||||
: 0;
|
||||
|
||||
// Timed grid excludes events that fill the full day on that day — they're
|
||||
// Timed grid excludes events that fill the full day on that day - they're
|
||||
// already promoted to the all-day strip above.
|
||||
const layoutsByDay = React.useMemo(() => {
|
||||
return weekDays.map((day) => {
|
||||
@@ -170,7 +170,7 @@ export function WeekView({
|
||||
{weekDays.map((day) => (
|
||||
<View key={day.toISOString()} style={styles.allDayCol} />
|
||||
))}
|
||||
{/* Segments overlay — multi-day events span their date range */}
|
||||
{/* Segments overlay - multi-day events span their date range */}
|
||||
<View style={styles.allDayOverlay} pointerEvents="box-none">
|
||||
{allDaySegments.map((segment) => {
|
||||
const color = getEventColor(segment.event, calendars);
|
||||
@@ -303,6 +303,8 @@ export function WeekView({
|
||||
);
|
||||
}
|
||||
|
||||
export const WeekView = React.memo(WeekViewInner);
|
||||
|
||||
function minutesToTimeLabel(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ const DEBUG_CATEGORIES = [
|
||||
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
|
||||
];
|
||||
|
||||
export function AdvancedSettings() {
|
||||
export function AboutDataSettings() {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
@@ -14,7 +14,7 @@ interface Session {
|
||||
}
|
||||
|
||||
const MOCK_SESSIONS: Session[] = [
|
||||
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export function NotificationSettings() {
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title="Push Notifications"
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing — never mail content."
|
||||
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing - never mail content."
|
||||
>
|
||||
<View style={styles.pushCard}>
|
||||
<View style={styles.pushHeader}>
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmailSettings() {
|
||||
export function ReadingSettings() {
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||
@@ -54,7 +54,7 @@ export function UpdatesSettings() {
|
||||
label="Latest version"
|
||||
description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined}
|
||||
>
|
||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '—'}</Text>
|
||||
<Text style={styles.value}>{cachedLatest ? cachedLatest.tag : '-'}</Text>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useVacationStore } from '../../stores/vacation-store';
|
||||
function toJmapDate(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" — normalize to "YYYY-MM-DDTHH:MM:SS".
|
||||
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" - normalize to "YYYY-MM-DDTHH:MM:SS".
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
|
||||
const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
|
||||
if (dateOnly) return `${trimmed}T00:00:00`;
|
||||
|
||||
@@ -14,7 +14,7 @@ function ev(partial: Partial<CalendarEvent>): CalendarEvent {
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
describe('expandRecurringEvents — daily', () => {
|
||||
describe('expandRecurringEvents - daily', () => {
|
||||
it('expands a daily rule with count', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
|
||||
const result = expandRecurringEvents(
|
||||
@@ -52,9 +52,9 @@ describe('expandRecurringEvents — daily', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — weekly', () => {
|
||||
describe('expandRecurringEvents - weekly', () => {
|
||||
it('expands a weekly rule on specific byDay values', () => {
|
||||
// April 6, 2026 is a Monday — expand Mon/Wed for two weeks.
|
||||
// April 6, 2026 is a Monday - expand Mon/Wed for two weeks.
|
||||
const rule: RecurrenceRule = {
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'mo' }, { day: 'we' }],
|
||||
@@ -74,7 +74,7 @@ describe('expandRecurringEvents — weekly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — monthly', () => {
|
||||
describe('expandRecurringEvents - monthly', () => {
|
||||
it('expands monthly on the 6th', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
|
||||
const result = expandRecurringEvents(
|
||||
@@ -108,7 +108,7 @@ describe('expandRecurringEvents — monthly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — overrides', () => {
|
||||
describe('expandRecurringEvents - overrides', () => {
|
||||
it('skips occurrences marked excluded', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 3 };
|
||||
const event = ev({
|
||||
@@ -146,7 +146,7 @@ describe('expandRecurringEvents — overrides', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — non-recurring passthrough', () => {
|
||||
describe('expandRecurringEvents - non-recurring passthrough', () => {
|
||||
it('returns non-recurring events unchanged', () => {
|
||||
const single = ev({ id: 'one', recurrenceRules: undefined });
|
||||
const result = expandRecurringEvents(
|
||||
@@ -159,7 +159,7 @@ describe('expandRecurringEvents — non-recurring passthrough', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents — range filtering', () => {
|
||||
describe('expandRecurringEvents - range filtering', () => {
|
||||
it('returns occurrences only within the requested window', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'daily', count: 30 };
|
||||
const result = expandRecurringEvents(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Two avatar schemes, ported verbatim from the webmail:
|
||||
//
|
||||
// 1. Account avatar (account-utils.ts) — 12-color fixed palette, hashed from
|
||||
// 1. Account avatar (account-utils.ts) - 12-color fixed palette, hashed from
|
||||
// the account's email. Used for the sidebar account block and account
|
||||
// switcher.
|
||||
//
|
||||
// 2. Email-row avatar (components/ui/avatar.tsx) — HSL hue from a hash of
|
||||
// 2. Email-row avatar (components/ui/avatar.tsx) - HSL hue from a hash of
|
||||
// name or email. Used for per-message avatars in the email list.
|
||||
//
|
||||
// Both variants also define `getInitials` with slightly different rules, so we
|
||||
@@ -68,7 +68,7 @@ export function getEmailInitials(name: string, email?: string): string {
|
||||
// logo from DuckDuckGo. The webmail goes through `/api/favicon?domain=...` as
|
||||
// a caching proxy; native has no CORS constraint, so we hit DuckDuckGo direct.
|
||||
|
||||
// Multi-part TLDs (trimmed to the webmail client-side list — not the giant
|
||||
// Multi-part TLDs (trimmed to the webmail client-side list - not the giant
|
||||
// server PSL). Covers the common cases so subdomains resolve to the right
|
||||
// registrable domain (e.g. newsletter.example.co.uk → example.co.uk).
|
||||
const MULTI_PART_TLDS = new Set([
|
||||
@@ -100,7 +100,7 @@ const MULTI_PART_TLDS = new Set([
|
||||
'co.ke', 'or.ke', 'ac.ke', 'go.ke', 'ne.ke',
|
||||
]);
|
||||
|
||||
// Personal mail providers — the favicon here is the mail provider's logo, not
|
||||
// Personal mail providers - the favicon here is the mail provider's logo, not
|
||||
// anything specific to the sender, so we skip it.
|
||||
const PERSONAL_DOMAINS = new Set([
|
||||
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com',
|
||||
|
||||
@@ -87,7 +87,7 @@ export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[]
|
||||
// O(days × events) with two parseISO calls per event per day. For ~200 events
|
||||
// that's ~16k parseISO calls per month render, redone on every swipe.
|
||||
// `buildEventDayIndex` parses each event's start/end once, then walks only the
|
||||
// days that event actually touches — typically 1 day. Result is a map from
|
||||
// days that event actually touches - typically 1 day. Result is a map from
|
||||
// local-date key (yyyy-MM-dd) to the events on that day.
|
||||
export type EventDayIndex = Map<string, CalendarEvent[]>;
|
||||
|
||||
@@ -104,7 +104,7 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex {
|
||||
const start = getEventStartDate(event);
|
||||
const end = getEventDisplayEndDate(event);
|
||||
let day = startOfDay(start);
|
||||
// Cap at 366 days: matches the original semantics (end > dayStart) — we
|
||||
// Cap at 366 days: matches the original semantics (end > dayStart) - we
|
||||
// include day while day < end. Safety bound guards against malformed
|
||||
// multi-year events that would otherwise blow up the loop.
|
||||
let safety = 0;
|
||||
@@ -193,7 +193,7 @@ export function getTimedEventBoundsForDay(
|
||||
// ─── Timed events that fill the day ──────────────────────
|
||||
// Events shown in the timed grid that effectively span 00:00–24:00 on the
|
||||
// given day get promoted to the all-day strip in the week/day views (matches
|
||||
// webmail behavior — keeps the timed grid usable when an event covers the day).
|
||||
// webmail behavior - keeps the timed grid usable when an event covers the day).
|
||||
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
|
||||
const bounds = getTimedEventBoundsForDay(event, day);
|
||||
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
|
||||
@@ -315,7 +315,7 @@ export function buildTimedFullDayWeekSegments(
|
||||
// begins after every prior event has ended. Within a cluster every event gets
|
||||
// the same `totalColumns`; across clusters, events get to use the full width
|
||||
// they actually need. The previous global packing forced an event with no
|
||||
// neighbors at 17:00 to share columns with a 9am cluster — visible squish.
|
||||
// neighbors at 17:00 to share columns with a 9am cluster - visible squish.
|
||||
export interface TimedEventLayout {
|
||||
event: CalendarEvent;
|
||||
column: number;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// so inline <script>, inline event handlers, and external resources are
|
||||
// blocked by the engine itself. This is the primary boundary.
|
||||
// 2. `onShouldStartLoadWithRequest` refuses any navigation except the
|
||||
// initial about:blank doc — links are opened in the OS browser.
|
||||
// initial about:blank doc - links are opened in the OS browser.
|
||||
// 3. This module pre-strips known-dangerous tags and on* attributes so the
|
||||
// rendered DOM is also clean. Regex stripping is imperfect; it is here
|
||||
// as belt-and-suspenders, not the primary defense.
|
||||
@@ -82,7 +82,7 @@ export function plainTextToSafeHtml(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Base styles for HTML emails — mirrors the webmail iframe body 1:1.
|
||||
// Base styles for HTML emails - mirrors the webmail iframe body 1:1.
|
||||
// Emails are authored for light mode; we render them true-to-life and apply a
|
||||
// filter inversion trick for dark mode (unless the email has native dark
|
||||
// support via @media (prefers-color-scheme: dark)).
|
||||
@@ -171,7 +171,7 @@ export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): str
|
||||
|
||||
// Strict CSP. Inline <script> and handlers from the email are stripped by
|
||||
// `stripDangerousTags`, so script-src only needs to allow our own injected
|
||||
// measurement bridge — 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// measurement bridge - 'unsafe-inline' is the minimum that lets RN WebView's
|
||||
// injected script run on Android (where page CSP can apply to evaluateJs).
|
||||
const imgSrc = blockRemoteImages ? "img-src data: cid:" : "img-src data: cid: https: http:";
|
||||
const csp = [
|
||||
@@ -286,7 +286,7 @@ function blockRemoteImageSrcs(html: string): string {
|
||||
|
||||
// Mirrors the webmail's `hasMeaningfulHtmlBody` (lib/signature-utils.ts).
|
||||
// Returns false when the HTML is an auto-generated minimal wrapper around plain
|
||||
// text (no <br>, no links, no rich tags) — the caller should fall back to the
|
||||
// text (no <br>, no links, no rich tags) - the caller should fall back to the
|
||||
// textBody in that case, since the server-side HTML often collapses newlines.
|
||||
const MEANINGFUL_HTML_RE =
|
||||
/<(?:table|img|style|b|strong|i|em|u|font|h[1-6]|ul|ol|blockquote|br)\b|<a\b[^>]*\bhref=|<(?:div|span|p)\b[^>]*\bstyle=/i;
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as FileSystemLegacy from 'expo-file-system/legacy';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import type { ReleaseAsset } from '../api/updates';
|
||||
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
|
||||
|
||||
export async function downloadAndInstallApk(asset: ReleaseAsset): Promise<void> {
|
||||
if (Platform.OS !== 'android') {
|
||||
throw new Error('APK install is only supported on Android');
|
||||
}
|
||||
|
||||
const dest = new File(Paths.cache, asset.name);
|
||||
if (dest.exists) dest.delete();
|
||||
const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType: 'application/vnd.android.package-archive',
|
||||
dialogTitle: 'Install update',
|
||||
UTI: 'com.android.package-archive',
|
||||
|
||||
const contentUri = await FileSystemLegacy.getContentUriAsync(downloaded.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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ interface BulwarkFcmNative {
|
||||
}
|
||||
|
||||
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
|
||||
// short-lived headless JS runtime — keep it fast, catch all errors, and always
|
||||
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -18,11 +18,11 @@ const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
|
||||
// Hosted relay so users don't need to run their own Firebase project. The
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes — no mail content.
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
|
||||
// Power users can override this from the settings screen.
|
||||
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
|
||||
|
||||
// Types the mobile app wants StateChange pings for. Submission is excluded —
|
||||
// Types the mobile app wants StateChange pings for. Submission is excluded -
|
||||
// outgoing mail state changes don't belong in a user-visible push.
|
||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
||||
|
||||
@@ -37,7 +37,7 @@ function getNative(): BulwarkFcmNative | null {
|
||||
}
|
||||
|
||||
export interface PushSetupParams {
|
||||
// Optional — falls back to the hosted relay if omitted.
|
||||
// Optional - falls back to the hosted relay if omitted.
|
||||
relayBaseUrl?: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export async function setupPushNotifications(
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
* FCM token. Never throws — callers treat teardown as best effort.
|
||||
* FCM token. Never throws - callers treat teardown as best effort.
|
||||
*/
|
||||
export async function teardownPushNotifications(): Promise<void> {
|
||||
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
@@ -266,7 +266,7 @@ export interface NotificationTapPayload {
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
// Returns — and clears — any pending "notification tap" that launched the app
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
// before JS was ready to handle it. Subsequent taps while running are delivered
|
||||
// via addNotificationTapListener.
|
||||
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
|
||||
|
||||
+499
-118
@@ -1,165 +1,546 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react-native';
|
||||
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isToday, addMonths, subMonths } from 'date-fns';
|
||||
import { colors } from '../theme/colors';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Menu,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isToday,
|
||||
addMonths,
|
||||
subMonths,
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
} from 'date-fns';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { Button } from '../components';
|
||||
import { useCalendarStore } from '../stores/calendar-store';
|
||||
import { MonthView } from '../components/calendar/MonthView';
|
||||
import { WeekView } from '../components/calendar/WeekView';
|
||||
import { AgendaView } from '../components/calendar/AgendaView';
|
||||
import { EventCard } from '../components/calendar/EventCard';
|
||||
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
|
||||
import { EventModal } from '../components/calendar/EventModal';
|
||||
import {
|
||||
RecurrenceScopeDialog,
|
||||
type RecurrenceEditScope,
|
||||
} from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
type EventDayIndex,
|
||||
} from '../lib/calendar-utils';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
|
||||
const MOCK_EVENTS = [
|
||||
{ id: '1', title: 'Team Standup', time: '09:00', color: '#3b82f6' },
|
||||
{ id: '2', title: 'Design Review', time: '11:00', color: '#8b5cf6' },
|
||||
{ id: '3', title: 'Lunch with Sarah', time: '12:30', color: '#22c55e' },
|
||||
{ id: '4', title: 'Sprint Planning', time: '14:00', color: '#f59e0b' },
|
||||
];
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
type PendingAction =
|
||||
| { kind: 'edit'; event: CalendarEvent }
|
||||
| { kind: 'delete'; event: CalendarEvent }
|
||||
| null;
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const AGENDA_DAYS = 30;
|
||||
const RANGE_BUFFER_DAYS = 14;
|
||||
|
||||
function MonthGrid({ currentDate }: { currentDate: Date }) {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calStart = startOfWeek(monthStart);
|
||||
const calEnd = endOfWeek(monthEnd);
|
||||
|
||||
const days: Date[] = [];
|
||||
let day = calStart;
|
||||
while (day <= calEnd) {
|
||||
days.push(day);
|
||||
day = addDays(day, 1);
|
||||
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
|
||||
if (viewMode === 'month') {
|
||||
const start = startOfWeek(startOfMonth(currentDate));
|
||||
const end = endOfWeek(endOfMonth(currentDate));
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
return {
|
||||
after: addDays(currentDate, -1),
|
||||
before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.weekdayRow}>
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<Text key={wd} style={styles.weekdayLabel}>{wd}</Text>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.daysGrid}>
|
||||
{days.map((d, i) => {
|
||||
const sameMonth = isSameMonth(d, currentDate);
|
||||
const today = isToday(d);
|
||||
return (
|
||||
<View key={i} style={styles.dayCell}>
|
||||
<View style={[styles.dayNumber, today && styles.todayCircle]}>
|
||||
<Text style={[
|
||||
styles.dayText,
|
||||
!sameMonth && styles.dayTextMuted,
|
||||
today && styles.todayText,
|
||||
]}>
|
||||
{format(d, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
|
||||
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
return format(currentDate, 'MMMM yyyy');
|
||||
}
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = React.useState(new Date());
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
|
||||
|
||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const hydrate = useCalendarStore((s) => s.hydrate);
|
||||
const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
|
||||
const ensureRange = useCalendarStore((s) => s.ensureRange);
|
||||
const refresh = useCalendarStore((s) => s.refresh);
|
||||
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const allCalendars = useCalendarStore((s) => s.calendars);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
const loading = useCalendarStore((s) => s.loading);
|
||||
const error = useCalendarStore((s) => s.error);
|
||||
const allEvents = useCalendarStore((s) => s.events);
|
||||
|
||||
const calendars = React.useMemo(
|
||||
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
|
||||
[allCalendars, hiddenCalendarIds],
|
||||
);
|
||||
const events = React.useMemo(() => {
|
||||
if (hiddenCalendarIds.length === 0) return allEvents;
|
||||
const hidden = new Set(hiddenCalendarIds);
|
||||
return allEvents.filter((e) => {
|
||||
const ids = Object.keys(e.calendarIds || {});
|
||||
if (ids.length === 0) return true;
|
||||
return ids.some((id) => !hidden.has(id));
|
||||
});
|
||||
}, [allEvents, hiddenCalendarIds]);
|
||||
// Pre-index events by day once. Child views do O(1) map lookups per cell
|
||||
// instead of re-filtering the full event list with parseISO per day.
|
||||
const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
void fetchCalendarsAction();
|
||||
}, [hydrate, fetchCalendarsAction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { after, before } = rangeForView(viewMode, currentDate);
|
||||
void ensureRange(after.toISOString(), before.toISOString());
|
||||
}, [viewMode, currentDate, ensureRange]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
if (viewMode === 'month') return subMonths(d, 1);
|
||||
if (viewMode === 'week') return subWeeks(d, 1);
|
||||
return addDays(d, -AGENDA_DAYS);
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
if (viewMode === 'month') return addMonths(d, 1);
|
||||
if (viewMode === 'week') return addWeeks(d, 1);
|
||||
return addDays(d, AGENDA_DAYS);
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
const goToday = React.useCallback(() => {
|
||||
const today = new Date();
|
||||
setCurrentDate(today);
|
||||
setSelectedDate(today);
|
||||
}, []);
|
||||
|
||||
const handleSelectDate = React.useCallback(
|
||||
(date: Date) => {
|
||||
setSelectedDate(date);
|
||||
if (viewMode === 'week') setCurrentDate(date);
|
||||
},
|
||||
[viewMode],
|
||||
);
|
||||
|
||||
const openCreate = React.useCallback((date?: Date) => {
|
||||
setModalEvent(null);
|
||||
setModalDate(date);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const openEditDirect = React.useCallback((event: CalendarEvent) => {
|
||||
setModalEvent(event);
|
||||
setModalDate(undefined);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
if (!action) return;
|
||||
// For the first pass, treat all scopes as "all" - proper override
|
||||
// handling lives in the store's update/delete path against originalId.
|
||||
// TODO: implement "this only" via recurrenceOverrides write, and
|
||||
// "this and following" via excludedRecurrenceRules + new master.
|
||||
if (action.kind === 'edit') {
|
||||
openEditDirect(action.event);
|
||||
} else {
|
||||
await deleteEvent(action.event.id);
|
||||
}
|
||||
},
|
||||
[pendingAction, openEditDirect, deleteEvent],
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (data: Partial<CalendarEvent>, calendarId: string) => {
|
||||
if (modalEvent) {
|
||||
await updateEvent(modalEvent.id, data);
|
||||
} else {
|
||||
await createEvent(data, calendarId);
|
||||
}
|
||||
},
|
||||
[modalEvent, createEvent, updateEvent],
|
||||
);
|
||||
|
||||
const handleDeleteFromModal = React.useCallback(
|
||||
async (event: CalendarEvent) => {
|
||||
setModalVisible(false);
|
||||
await deleteEvent(event.id);
|
||||
},
|
||||
[deleteEvent],
|
||||
);
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const isSelectedToday = isToday(selectedDate);
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<CalendarDays size={24} color={colors.primary} />
|
||||
<Text style={styles.headerTitle}>Calendar</Text>
|
||||
<Pressable
|
||||
onPress={() => setSidebarVisible(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Menu size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode}
|
||||
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||
onPress={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.monthNav}>
|
||||
<Pressable onPress={() => setCurrentDate(subMonths(currentDate, 1))} style={styles.navBtn}>
|
||||
<View style={styles.nav}>
|
||||
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.monthLabel}>{format(currentDate, 'MMMM yyyy')}</Text>
|
||||
<Pressable onPress={() => setCurrentDate(addMonths(currentDate, 1))} style={styles.navBtn}>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<MonthGrid currentDate={currentDate} />
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.todaySection}>
|
||||
<Text style={styles.todaySectionTitle}>Today's Events</Text>
|
||||
{MOCK_EVENTS.map((event) => (
|
||||
<Pressable key={event.id} style={styles.eventCard}>
|
||||
<View style={[styles.eventDot, { backgroundColor: event.color }]} />
|
||||
<View style={styles.eventContent}>
|
||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||
<Text style={styles.eventTime}>{event.time}</Text>
|
||||
<View style={styles.content}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekView
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
<AgendaView
|
||||
fromDate={currentDate}
|
||||
daysAhead={AGENDA_DAYS}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<View style={styles.dayDetail}>
|
||||
<View style={styles.dayDetailHeader}>
|
||||
<Text style={styles.dayDetailTitle}>
|
||||
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
<DayEventList
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
/>
|
||||
|
||||
<RecurrenceScopeDialog
|
||||
visible={!!pendingAction}
|
||||
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingAction(null)}
|
||||
/>
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function DayEventList({
|
||||
date,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
}: {
|
||||
date: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const dayEvents = React.useMemo(
|
||||
() => eventsOnDayFromIndex(eventsByDay, date),
|
||||
[eventsByDay, date],
|
||||
);
|
||||
if (dayEvents.length === 0) {
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emptyState}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dayList}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
||||
monthNav: {
|
||||
flexDirection: 'row',
|
||||
headerBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
navBtn: { padding: 8 },
|
||||
monthLabel: { fontSize: 17, fontWeight: '600', color: colors.text },
|
||||
weekdayRow: {
|
||||
headerLeft: { flex: 1 },
|
||||
headerTitle: { ...typography.h3, color: colors.text },
|
||||
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
viewToggle: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 8,
|
||||
marginBottom: 4,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.md,
|
||||
padding: 2,
|
||||
},
|
||||
weekdayLabel: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
},
|
||||
daysGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
dayCell: {
|
||||
width: '14.28%',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
dayNumber: {
|
||||
viewToggleBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
viewToggleBtnActive: { backgroundColor: colors.background },
|
||||
fab: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayText: { fontSize: 14, color: colors.text },
|
||||
dayTextMuted: { color: colors.textMuted },
|
||||
todayCircle: { backgroundColor: colors.primary },
|
||||
todayText: { color: colors.textInverse, fontWeight: '700' },
|
||||
todaySection: { paddingHorizontal: 16, paddingTop: 20 },
|
||||
todaySectionTitle: { fontSize: 16, fontWeight: '600', color: colors.text, marginBottom: 12 },
|
||||
eventCard: {
|
||||
|
||||
nav: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
eventDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
|
||||
eventContent: { flex: 1 },
|
||||
eventTitle: { fontSize: 15, fontWeight: '500', color: colors.text },
|
||||
eventTime: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
navBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
errorBanner: {
|
||||
backgroundColor: colors.errorBg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
content: { flex: 1 },
|
||||
|
||||
dayDetail: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
dayDetailHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
|
||||
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
gap: spacing.xs,
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Menu,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isToday,
|
||||
addMonths,
|
||||
subMonths,
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
} from 'date-fns';
|
||||
import { colors, spacing, radius, typography } from '../theme/tokens';
|
||||
import { Button } from '../components';
|
||||
import { useCalendarStore } from '../stores/calendar-store';
|
||||
import { MonthView } from '../components/calendar/MonthView';
|
||||
import { WeekView } from '../components/calendar/WeekView';
|
||||
import { AgendaView } from '../components/calendar/AgendaView';
|
||||
import { EventCard } from '../components/calendar/EventCard';
|
||||
import { EventDetailSheet } from '../components/calendar/EventDetailSheet';
|
||||
import { EventModal } from '../components/calendar/EventModal';
|
||||
import {
|
||||
RecurrenceScopeDialog,
|
||||
type RecurrenceEditScope,
|
||||
} from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
type EventDayIndex,
|
||||
} from '../lib/calendar-utils';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
type PendingAction =
|
||||
| { kind: 'edit'; event: CalendarEvent }
|
||||
| { kind: 'delete'; event: CalendarEvent }
|
||||
| null;
|
||||
|
||||
const AGENDA_DAYS = 30;
|
||||
const RANGE_BUFFER_DAYS = 14;
|
||||
|
||||
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
|
||||
if (viewMode === 'month') {
|
||||
const start = startOfWeek(startOfMonth(currentDate));
|
||||
const end = endOfWeek(endOfMonth(currentDate));
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
|
||||
}
|
||||
return {
|
||||
after: addDays(currentDate, -1),
|
||||
before: addDays(currentDate, AGENDA_DAYS + RANGE_BUFFER_DAYS),
|
||||
};
|
||||
}
|
||||
|
||||
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
|
||||
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
|
||||
if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate);
|
||||
const end = endOfWeek(currentDate);
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
return format(currentDate, 'MMMM yyyy');
|
||||
}
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = React.useState(new Date());
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
|
||||
|
||||
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
|
||||
const [modalDate, setModalDate] = React.useState<Date | undefined>(undefined);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const hydrate = useCalendarStore((s) => s.hydrate);
|
||||
const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars);
|
||||
const ensureRange = useCalendarStore((s) => s.ensureRange);
|
||||
const refresh = useCalendarStore((s) => s.refresh);
|
||||
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const allCalendars = useCalendarStore((s) => s.calendars);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
const loading = useCalendarStore((s) => s.loading);
|
||||
const error = useCalendarStore((s) => s.error);
|
||||
const allEvents = useCalendarStore((s) => s.events);
|
||||
|
||||
const calendars = React.useMemo(
|
||||
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
|
||||
[allCalendars, hiddenCalendarIds],
|
||||
);
|
||||
const events = React.useMemo(() => {
|
||||
if (hiddenCalendarIds.length === 0) return allEvents;
|
||||
const hidden = new Set(hiddenCalendarIds);
|
||||
return allEvents.filter((e) => {
|
||||
const ids = Object.keys(e.calendarIds || {});
|
||||
if (ids.length === 0) return true;
|
||||
return ids.some((id) => !hidden.has(id));
|
||||
});
|
||||
}, [allEvents, hiddenCalendarIds]);
|
||||
// Pre-index events by day once. Child views do O(1) map lookups per cell
|
||||
// instead of re-filtering the full event list with parseISO per day.
|
||||
const eventsByDay = React.useMemo(() => buildEventDayIndex(events), [events]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
void fetchCalendarsAction();
|
||||
}, [hydrate, fetchCalendarsAction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const { after, before } = rangeForView(viewMode, currentDate);
|
||||
void ensureRange(after.toISOString(), before.toISOString());
|
||||
}, [viewMode, currentDate, ensureRange]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
if (viewMode === 'month') return subMonths(d, 1);
|
||||
if (viewMode === 'week') return subWeeks(d, 1);
|
||||
return addDays(d, -AGENDA_DAYS);
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
setCurrentDate((d) => {
|
||||
if (viewMode === 'month') return addMonths(d, 1);
|
||||
if (viewMode === 'week') return addWeeks(d, 1);
|
||||
return addDays(d, AGENDA_DAYS);
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
const goToday = React.useCallback(() => {
|
||||
const today = new Date();
|
||||
setCurrentDate(today);
|
||||
setSelectedDate(today);
|
||||
}, []);
|
||||
|
||||
const handleSelectDate = React.useCallback(
|
||||
(date: Date) => {
|
||||
setSelectedDate(date);
|
||||
if (viewMode === 'week') setCurrentDate(date);
|
||||
},
|
||||
[viewMode],
|
||||
);
|
||||
|
||||
const openCreate = React.useCallback((date?: Date) => {
|
||||
setModalEvent(null);
|
||||
setModalDate(date);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const openEditDirect = React.useCallback((event: CalendarEvent) => {
|
||||
setModalEvent(event);
|
||||
setModalDate(undefined);
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
const action = pendingAction;
|
||||
setPendingAction(null);
|
||||
if (!action) return;
|
||||
// For the first pass, treat all scopes as "all" — proper override
|
||||
// handling lives in the store's update/delete path against originalId.
|
||||
// TODO: implement "this only" via recurrenceOverrides write, and
|
||||
// "this and following" via excludedRecurrenceRules + new master.
|
||||
if (action.kind === 'edit') {
|
||||
openEditDirect(action.event);
|
||||
} else {
|
||||
await deleteEvent(action.event.id);
|
||||
}
|
||||
},
|
||||
[pendingAction, openEditDirect, deleteEvent],
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(
|
||||
async (data: Partial<CalendarEvent>, calendarId: string) => {
|
||||
if (modalEvent) {
|
||||
await updateEvent(modalEvent.id, data);
|
||||
} else {
|
||||
await createEvent(data, calendarId);
|
||||
}
|
||||
},
|
||||
[modalEvent, createEvent, updateEvent],
|
||||
);
|
||||
|
||||
const handleDeleteFromModal = React.useCallback(
|
||||
async (event: CalendarEvent) => {
|
||||
setModalVisible(false);
|
||||
await deleteEvent(event.id);
|
||||
},
|
||||
[deleteEvent],
|
||||
);
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const isSelectedToday = isToday(selectedDate);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSidebarVisible(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
>
|
||||
<Menu size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
mode === 'month' ? LayoutGrid : mode === 'week' ? CalendarDays : ListIcon;
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode}
|
||||
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
|
||||
onPress={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={16} color={active ? colors.primary : colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.fab} onPress={() => openCreate(selectedDate)}>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.nav}>
|
||||
<Pressable onPress={goPrev} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Button variant="outline" size="sm" onPress={goToday}>
|
||||
Today
|
||||
</Button>
|
||||
<Pressable onPress={goNext} style={styles.navBtn} hitSlop={8}>
|
||||
<ChevronRight size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekView
|
||||
selectedDate={selectedDate}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={(date) => openCreate(date)}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
<AgendaView
|
||||
fromDate={currentDate}
|
||||
daysAhead={AGENDA_DAYS}
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<View style={styles.dayDetail}>
|
||||
<View style={styles.dayDetailHeader}>
|
||||
<Text style={styles.dayDetailTitle}>
|
||||
{isSelectedToday ? "Today's Events" : format(selectedDate, 'EEEE, MMMM d')}
|
||||
</Text>
|
||||
{loading && <ActivityIndicator size="small" color={colors.textMuted} />}
|
||||
</View>
|
||||
<DayEventList
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
onClose={() => setModalVisible(false)}
|
||||
/>
|
||||
|
||||
<RecurrenceScopeDialog
|
||||
visible={!!pendingAction}
|
||||
actionType={pendingAction?.kind === 'delete' ? 'delete' : 'edit'}
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingAction(null)}
|
||||
/>
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function DayEventList({
|
||||
date,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
}: {
|
||||
date: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const dayEvents = React.useMemo(
|
||||
() => eventsOnDayFromIndex(eventsByDay, date),
|
||||
[eventsByDay, date],
|
||||
);
|
||||
if (dayEvents.length === 0) {
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emptyState}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
<CalendarDays size={32} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>No events</Text>
|
||||
<Text style={styles.emptySubtitle}>Tap + to create one</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dayList}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.textMuted} />
|
||||
}
|
||||
>
|
||||
{dayEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerLeft: { flex: 1 },
|
||||
headerTitle: { ...typography.h3, color: colors.text },
|
||||
headerSubtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
viewToggle: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.md,
|
||||
padding: 2,
|
||||
},
|
||||
viewToggleBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
viewToggleBtnActive: { backgroundColor: colors.background },
|
||||
fab: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
nav: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
navBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
errorBanner: {
|
||||
backgroundColor: colors.errorBg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
content: { flex: 1 },
|
||||
|
||||
dayDetail: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
dayDetailHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
dayDetailTitle: { ...typography.bodyMedium, color: colors.text },
|
||||
dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
gap: spacing.xs,
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
+460
-70
@@ -1,110 +1,500 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SectionList, Pressable } from 'react-native';
|
||||
import { Users } from 'lucide-react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
import {
|
||||
useContactsStore,
|
||||
sortContactsByDisplayName,
|
||||
selectGroupMembers,
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { ContactListRow } from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
const MOCK_CONTACTS = [
|
||||
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', phone: '+1 555-0101' },
|
||||
{ id: '2', name: 'Bob Smith', email: 'bob@example.com', phone: '+1 555-0102' },
|
||||
{ id: '3', name: 'Carol Davis', email: 'carol@example.com', phone: '+1 555-0103' },
|
||||
{ id: '4', name: 'David Wilson', email: 'david@example.com', phone: '+1 555-0104' },
|
||||
{ id: '5', name: 'Emma Brown', email: 'emma@example.com', phone: '+1 555-0105' },
|
||||
{ id: '6', name: 'Frank Miller', email: 'frank@example.com', phone: '+1 555-0106' },
|
||||
{ id: '7', name: 'Grace Lee', email: 'grace@example.com', phone: '+1 555-0107' },
|
||||
{ id: '8', name: 'Henry Taylor', email: 'henry@example.com', phone: '+1 555-0108' },
|
||||
];
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function groupByLetter(contacts: typeof MOCK_CONTACTS) {
|
||||
const groups: Record<string, typeof MOCK_CONTACTS> = {};
|
||||
contacts.forEach((c) => {
|
||||
const letter = c.name.charAt(0).toUpperCase();
|
||||
if (!groups[letter]) groups[letter] = [];
|
||||
groups[letter].push(c);
|
||||
});
|
||||
return Object.keys(groups)
|
||||
.sort()
|
||||
.map((letter) => ({ title: letter, data: groups[letter] }));
|
||||
interface Section {
|
||||
title: string;
|
||||
data: ContactCard[];
|
||||
}
|
||||
|
||||
function ContactRow({ item }: { item: typeof MOCK_CONTACTS[0] }) {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => [styles.contactRow, pressed && styles.contactRowPressed]}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{item.name.charAt(0)}</Text>
|
||||
</View>
|
||||
<View style={styles.contactInfo}>
|
||||
<Text style={styles.contactName}>{item.name}</Text>
|
||||
<Text style={styles.contactEmail}>{item.email}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const sorted = sortContactsByDisplayName(contacts);
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
for (const c of sorted) {
|
||||
const name = getContactDisplayName(c).trim();
|
||||
const letter = (name[0] || '#').toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : '#';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(c);
|
||||
}
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
if (a === '#') return 1;
|
||||
if (b === '#') return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((title) => ({ title, data: groups[title] }));
|
||||
}
|
||||
|
||||
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
|
||||
switch (category.type) {
|
||||
case 'all':
|
||||
return 'All Contacts';
|
||||
case 'addressBook':
|
||||
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
|
||||
case 'group': {
|
||||
const g = groups.find((c) => c.id === category.groupId);
|
||||
return g ? getContactDisplayName(g) || 'Group' : 'Group';
|
||||
}
|
||||
case 'keyword':
|
||||
return `#${category.keyword}`;
|
||||
case 'uncategorized':
|
||||
return 'Uncategorized';
|
||||
default:
|
||||
return 'Contacts';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactsScreen() {
|
||||
const sections = React.useMemo(() => groupByLetter(MOCK_CONTACTS), []);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const loading = useContactsStore((s) => s.loading);
|
||||
const error = useContactsStore((s) => s.error);
|
||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||
const hydrated = useContactsStore((s) => s.hydrated);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [searchActive, setSearchActive] = React.useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchAddressBooks();
|
||||
void fetchContacts();
|
||||
}, [fetchAddressBooks, fetchContacts]);
|
||||
|
||||
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
||||
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
let filtered: ContactCard[];
|
||||
switch (selectedCategory.type) {
|
||||
case 'all':
|
||||
filtered = individuals;
|
||||
break;
|
||||
case 'addressBook':
|
||||
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
|
||||
break;
|
||||
case 'group':
|
||||
filtered = selectGroupMembers(
|
||||
{ contacts } as Parameters<typeof selectGroupMembers>[0],
|
||||
selectedCategory.groupId,
|
||||
).filter((c) => !isGroup(c));
|
||||
break;
|
||||
case 'keyword':
|
||||
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
|
||||
break;
|
||||
case 'uncategorized':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
}
|
||||
if (!searchQuery) return filtered;
|
||||
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||
}, [contacts, individuals, selectedCategory, searchQuery]);
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list - single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
const selectionMode = selection.size > 0;
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowPress = (c: ContactCard) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(c.id);
|
||||
return;
|
||||
}
|
||||
if (isGroup(c)) {
|
||||
navigation.navigate('GroupDetail', { groupId: c.id });
|
||||
} else {
|
||||
navigation.navigate('ContactDetail', { contactId: c.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = (c: ContactCard) => {
|
||||
toggleSelect(c.id);
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelection(new Set());
|
||||
|
||||
const doBulkDelete = async () => {
|
||||
const ids = Array.from(selection);
|
||||
setConfirmBulkDelete(false);
|
||||
clearSelection();
|
||||
await bulkDelete(ids);
|
||||
};
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
const chips = React.useMemo(() => {
|
||||
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
category: { type: 'all' },
|
||||
active: selectedCategory.type === 'all',
|
||||
},
|
||||
];
|
||||
for (const book of addressBooks) {
|
||||
items.push({
|
||||
key: `book:${book.id}`,
|
||||
label: book.name,
|
||||
category: { type: 'addressBook', addressBookId: book.id },
|
||||
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
|
||||
});
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
items.push({
|
||||
key: 'groups-divider',
|
||||
label: 'Groups',
|
||||
category: { type: 'all' },
|
||||
active: false,
|
||||
});
|
||||
for (const g of groups) {
|
||||
items.push({
|
||||
key: `group:${g.id}`,
|
||||
label: getContactDisplayName(g) || 'Group',
|
||||
category: { type: 'group', groupId: g.id },
|
||||
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [addressBooks, groups, selectedCategory]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Users size={24} color={colors.primary} />
|
||||
<Text style={styles.headerTitle}>Contacts</Text>
|
||||
<Text style={styles.headerCount}>{MOCK_CONTACTS.length}</Text>
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<X size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<Text style={styles.headerCount}>{visible.length}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setSearchActive((v) => !v)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searchActive && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!selectionMode && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.chipBar}
|
||||
contentContainerStyle={styles.chipBarContent}
|
||||
>
|
||||
{chips.map((chip) =>
|
||||
chip.key === 'groups-divider' ? (
|
||||
<View key={chip.key} style={styles.chipDivider} />
|
||||
) : (
|
||||
<Pressable
|
||||
key={chip.key}
|
||||
onPress={() => setSelectedCategory(chip.category)}
|
||||
style={[styles.chip, chip.active && styles.chipActive]}
|
||||
>
|
||||
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <ContactRow item={item} />}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
renderItem={({ item }) => (
|
||||
<ContactListRow
|
||||
contact={item}
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleLongPress(item)}
|
||||
selected={selection.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
)}
|
||||
renderSectionHeader={({ section }) =>
|
||||
section.title ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionHeaderText}>{section.title}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
stickySectionHeadersEnabled
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading && hydrated}
|
||||
onRefresh={() => {
|
||||
void fetchContacts();
|
||||
void fetchAddressBooks();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
title="Delete contacts"
|
||||
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text, flex: 1 },
|
||||
headerCount: { fontSize: 13, color: colors.textSecondary },
|
||||
listContent: { paddingBottom: 20 },
|
||||
sectionHeader: {
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.surface,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionTitle: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
||||
contactRow: {
|
||||
headerActions: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
contactRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
avatar: {
|
||||
headerIconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primaryLight,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
avatarText: { fontSize: 16, fontWeight: '600', color: colors.textInverse },
|
||||
contactInfo: { flex: 1 },
|
||||
contactName: { fontSize: 15, fontWeight: '500', color: colors.text },
|
||||
contactEmail: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
||||
|
||||
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
|
||||
chipBarContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
import {
|
||||
useContactsStore,
|
||||
sortContactsByDisplayName,
|
||||
selectGroupMembers,
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { ContactListRow } from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
data: ContactCard[];
|
||||
}
|
||||
|
||||
function groupContacts(contacts: ContactCard[]): Section[] {
|
||||
const sorted = sortContactsByDisplayName(contacts);
|
||||
const groups: Record<string, ContactCard[]> = {};
|
||||
for (const c of sorted) {
|
||||
const name = getContactDisplayName(c).trim();
|
||||
const letter = (name[0] || '#').toUpperCase();
|
||||
const key = /[A-Z]/.test(letter) ? letter : '#';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(c);
|
||||
}
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
if (a === '#') return 1;
|
||||
if (b === '#') return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((title) => ({ title, data: groups[title] }));
|
||||
}
|
||||
|
||||
function categoryTitle(category: ContactCategory, books: Array<{ id: string; name: string }>, groups: ContactCard[]): string {
|
||||
switch (category.type) {
|
||||
case 'all':
|
||||
return 'All Contacts';
|
||||
case 'addressBook':
|
||||
return books.find((b) => b.id === category.addressBookId)?.name || 'Address Book';
|
||||
case 'group': {
|
||||
const g = groups.find((c) => c.id === category.groupId);
|
||||
return g ? getContactDisplayName(g) || 'Group' : 'Group';
|
||||
}
|
||||
case 'keyword':
|
||||
return `#${category.keyword}`;
|
||||
case 'uncategorized':
|
||||
return 'Uncategorized';
|
||||
default:
|
||||
return 'Contacts';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContactsScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const loading = useContactsStore((s) => s.loading);
|
||||
const error = useContactsStore((s) => s.error);
|
||||
const selectedCategory = useContactsStore((s) => s.selectedCategory);
|
||||
const hydrated = useContactsStore((s) => s.hydrated);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [searchActive, setSearchActive] = React.useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchAddressBooks();
|
||||
void fetchContacts();
|
||||
}, [fetchAddressBooks, fetchContacts]);
|
||||
|
||||
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
|
||||
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
|
||||
const visible = React.useMemo(() => {
|
||||
let filtered: ContactCard[];
|
||||
switch (selectedCategory.type) {
|
||||
case 'all':
|
||||
filtered = individuals;
|
||||
break;
|
||||
case 'addressBook':
|
||||
filtered = individuals.filter((c) => c.addressBookIds?.[selectedCategory.addressBookId]);
|
||||
break;
|
||||
case 'group':
|
||||
filtered = selectGroupMembers(
|
||||
{ contacts } as Parameters<typeof selectGroupMembers>[0],
|
||||
selectedCategory.groupId,
|
||||
).filter((c) => !isGroup(c));
|
||||
break;
|
||||
case 'keyword':
|
||||
filtered = individuals.filter((c) => c.keywords?.[selectedCategory.keyword]);
|
||||
break;
|
||||
case 'uncategorized':
|
||||
filtered = individuals.filter(
|
||||
(c) => !c.addressBookIds || Object.keys(c.addressBookIds).length === 0,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
filtered = individuals;
|
||||
}
|
||||
if (!searchQuery) return filtered;
|
||||
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||
}, [contacts, individuals, selectedCategory, searchQuery]);
|
||||
|
||||
const sections = React.useMemo<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible);
|
||||
// Flat list — single unnamed section keeps SectionList rendering simple.
|
||||
return [{ title: '', data: sortContactsByDisplayName(visible) }];
|
||||
}, [visible, groupByLetter]);
|
||||
|
||||
const selectionMode = selection.size > 0;
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowPress = (c: ContactCard) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(c.id);
|
||||
return;
|
||||
}
|
||||
if (isGroup(c)) {
|
||||
navigation.navigate('GroupDetail', { groupId: c.id });
|
||||
} else {
|
||||
navigation.navigate('ContactDetail', { contactId: c.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleLongPress = (c: ContactCard) => {
|
||||
toggleSelect(c.id);
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelection(new Set());
|
||||
|
||||
const doBulkDelete = async () => {
|
||||
const ids = Array.from(selection);
|
||||
setConfirmBulkDelete(false);
|
||||
clearSelection();
|
||||
await bulkDelete(ids);
|
||||
};
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
const chips = React.useMemo(() => {
|
||||
const items: Array<{ key: string; label: string; category: ContactCategory; active: boolean }> = [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
category: { type: 'all' },
|
||||
active: selectedCategory.type === 'all',
|
||||
},
|
||||
];
|
||||
for (const book of addressBooks) {
|
||||
items.push({
|
||||
key: `book:${book.id}`,
|
||||
label: book.name,
|
||||
category: { type: 'addressBook', addressBookId: book.id },
|
||||
active: selectedCategory.type === 'addressBook' && selectedCategory.addressBookId === book.id,
|
||||
});
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
items.push({
|
||||
key: 'groups-divider',
|
||||
label: 'Groups',
|
||||
category: { type: 'all' },
|
||||
active: false,
|
||||
});
|
||||
for (const g of groups) {
|
||||
items.push({
|
||||
key: `group:${g.id}`,
|
||||
label: getContactDisplayName(g) || 'Group',
|
||||
category: { type: 'group', groupId: g.id },
|
||||
active: selectedCategory.type === 'group' && selectedCategory.groupId === g.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [addressBooks, groups, selectedCategory]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ContactsSidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
|
||||
<View style={styles.header}>
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<Pressable onPress={clearSelection} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<X size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Trash2 size={20} color={colors.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerIconBtn} hitSlop={8}>
|
||||
<Menu size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<Text style={styles.headerCount}>{visible.length}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setSearchActive((v) => !v)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Search size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Plus size={18} color={colors.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{searchActive && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search contacts..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable onPress={() => setSearchQuery('')} hitSlop={8}>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!selectionMode && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.chipBar}
|
||||
contentContainerStyle={styles.chipBarContent}
|
||||
>
|
||||
{chips.map((chip) =>
|
||||
chip.key === 'groups-divider' ? (
|
||||
<View key={chip.key} style={styles.chipDivider} />
|
||||
) : (
|
||||
<Pressable
|
||||
key={chip.key}
|
||||
onPress={() => setSelectedCategory(chip.category)}
|
||||
style={[styles.chip, chip.active && styles.chipActive]}
|
||||
>
|
||||
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ContactListRow
|
||||
contact={item}
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleLongPress(item)}
|
||||
selected={selection.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
/>
|
||||
)}
|
||||
renderSectionHeader={({ section }) =>
|
||||
section.title ? (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionHeaderText}>{section.title}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
stickySectionHeadersEnabled
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading && hydrated}
|
||||
onRefresh={() => {
|
||||
void fetchContacts();
|
||||
void fetchAddressBooks();
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<UserCircle size={48} color={colors.surfaceActive} />
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'No contacts found' : 'No contacts yet'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{searchQuery ? 'Try a different search' : 'Tap + to add one'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
title="Delete contacts"
|
||||
message={`Delete ${selection.size} contact${selection.size === 1 ? '' : 's'}? This cannot be undone.`}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
listContent: { paddingBottom: 80 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: colors.text, flexShrink: 1 },
|
||||
headerCount: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerActions: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerIconBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
|
||||
|
||||
chipBar: { flexGrow: 0, marginBottom: spacing.sm },
|
||||
chipBarContent: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
chipText: { ...typography.captionMedium, color: colors.textSecondary },
|
||||
chipTextActive: { color: colors.primaryForeground },
|
||||
chipDivider: {
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: colors.primary },
|
||||
|
||||
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
|
||||
|
||||
errorBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: colors.errorForeground },
|
||||
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xxxl * 2,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
|
||||
emptySubtitle: { ...typography.caption, color: colors.textMuted },
|
||||
});
|
||||
@@ -43,7 +43,7 @@ function formatRelativeTime(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function EmailRow({
|
||||
const EmailRow = React.memo(function EmailRow({
|
||||
item,
|
||||
onPress,
|
||||
onLongPress,
|
||||
@@ -51,8 +51,8 @@ function EmailRow({
|
||||
selectionMode,
|
||||
}: {
|
||||
item: Email;
|
||||
onPress: () => void;
|
||||
onLongPress: () => void;
|
||||
onPress: (id: string) => void;
|
||||
onLongPress: (id: string) => void;
|
||||
selected: boolean;
|
||||
selectionMode: boolean;
|
||||
}) {
|
||||
@@ -61,6 +61,9 @@ function EmailRow({
|
||||
const unread = isUnread(item);
|
||||
const starred = isStarred(item);
|
||||
|
||||
const handlePress = React.useCallback(() => onPress(item.id), [onPress, item.id]);
|
||||
const handleLongPress = React.useCallback(() => onLongPress(item.id), [onLongPress, item.id]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
@@ -68,8 +71,8 @@ function EmailRow({
|
||||
pressed && styles.emailRowPressed,
|
||||
selected && styles.emailRowSelected,
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
>
|
||||
{selectionMode && (
|
||||
@@ -117,7 +120,11 @@ function EmailRow({
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const EmailRowSeparator = () => <View style={styles.separator} />;
|
||||
|
||||
const emailKeyExtractor = (item: Email) => item.id;
|
||||
|
||||
interface EmailListScreenProps {
|
||||
onEmailPress?: (email: Email) => void;
|
||||
@@ -152,6 +159,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const allSelected =
|
||||
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
|
||||
|
||||
// Refs so row press handlers stay referentially stable across renders.
|
||||
// FlatList rows then skip re-render when the parent re-renders for unrelated
|
||||
// reasons (e.g. opening the filter modal).
|
||||
const onEmailPressRef = React.useRef(onEmailPress);
|
||||
React.useEffect(() => { onEmailPressRef.current = onEmailPress; }, [onEmailPress]);
|
||||
const selectionModeRef = React.useRef(selectionMode);
|
||||
React.useEffect(() => { selectionModeRef.current = selectionMode; }, [selectionMode]);
|
||||
const emailsRef = React.useRef(emails);
|
||||
React.useEffect(() => { emailsRef.current = emails; }, [emails]);
|
||||
|
||||
const toggleSelect = React.useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -161,6 +178,28 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRowPress = React.useCallback((id: string) => {
|
||||
if (selectionModeRef.current) {
|
||||
toggleSelect(id);
|
||||
} else {
|
||||
const email = emailsRef.current.find((e) => e.id === id);
|
||||
if (email) onEmailPressRef.current?.(email);
|
||||
}
|
||||
}, [toggleSelect]);
|
||||
|
||||
const renderEmailRow = React.useCallback(
|
||||
({ item }: { item: Email }) => (
|
||||
<EmailRow
|
||||
item={item}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onPress={handleRowPress}
|
||||
onLongPress={toggleSelect}
|
||||
/>
|
||||
),
|
||||
[selectedIds, selectionMode, handleRowPress, toggleSelect],
|
||||
);
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
@@ -488,20 +527,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
) : (
|
||||
<FlatList
|
||||
data={emails}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<EmailRow
|
||||
item={item}
|
||||
selected={selectedIds.has(item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onPress={() => {
|
||||
if (selectionMode) toggleSelect(item.id);
|
||||
else onEmailPress?.(item);
|
||||
}}
|
||||
onLongPress={() => toggleSelect(item.id)}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
keyExtractor={emailKeyExtractor}
|
||||
renderItem={renderEmailRow}
|
||||
ItemSeparatorComponent={EmailRowSeparator}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={() => { void loadMoreEmails(); }}
|
||||
onEndReachedThreshold={0.3}
|
||||
@@ -510,7 +538,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Compose FAB — matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
<Pressable
|
||||
onPress={onComposePress}
|
||||
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
|
||||
@@ -749,7 +777,7 @@ function TriToggle({
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -770,7 +798,7 @@ const styles = StyleSheet.create({
|
||||
height: 28,
|
||||
},
|
||||
|
||||
// Search — matches web search bar styling
|
||||
// Search - matches web search bar styling
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1026,7 +1054,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.primary,
|
||||
},
|
||||
|
||||
// Email list — matches web email-list-item
|
||||
// Email list - matches web email-list-item
|
||||
listContent: { paddingBottom: 100 },
|
||||
emailRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -1111,7 +1139,7 @@ const styles = StyleSheet.create({
|
||||
tagText: { ...typography.small, fontWeight: '500' },
|
||||
separator: { height: 0 }, // borders are on rows now
|
||||
|
||||
// Compose FAB — matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
|
||||
// Compose FAB - matches webmail: absolute bottom-4 right-4, h-14 w-14, rounded-full, bg-primary, shadow-lg
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: spacing.lg, // right-4
|
||||
@@ -1119,7 +1147,7 @@ const styles = StyleSheet.create({
|
||||
width: componentSizes.fab, // 56px (h-14)
|
||||
height: componentSizes.fab, // 56px (w-14)
|
||||
borderRadius: radius.full, // rounded-full (circle)
|
||||
backgroundColor: colors.text, // white — matches webmail mobile FAB
|
||||
backgroundColor: colors.text, // white - matches webmail mobile FAB
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 6,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -1,20 +1,37 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||
Modal, useWindowDimensions, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Paperclip,
|
||||
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Inbox, Send, File as FileIcon, Ban, Folder,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import EmailBodyView from '../components/EmailBodyView';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import type { Email } from '../api/types';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
||||
return Folder;
|
||||
}
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
|
||||
|
||||
function formatHeaderDate(iso: string): string {
|
||||
@@ -43,16 +60,31 @@ function plainTextBody(email: Email): string {
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const { emailId, subject: subjectParam } = route.params;
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
|
||||
const markRead = useEmailStore((s) => s.markRead);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const emails = useEmailStore((s) => s.emails);
|
||||
|
||||
const currentIndex = emails.findIndex((e) => e.id === emailId);
|
||||
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
|
||||
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
|
||||
const goToEmail = (target: { id: string; threadId: string; subject: string }) => {
|
||||
navigation.replace('EmailThread', {
|
||||
emailId: target.id,
|
||||
threadId: target.threadId,
|
||||
subject: target.subject,
|
||||
});
|
||||
};
|
||||
|
||||
const [email, setEmail] = React.useState<Email | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -78,16 +110,46 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
}, [emailId, getEmailDetail, markRead]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
|
||||
const archiveMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive'),
|
||||
[mailboxes],
|
||||
);
|
||||
const junkMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
|
||||
[mailboxes],
|
||||
);
|
||||
const inboxMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'inbox'),
|
||||
[mailboxes],
|
||||
);
|
||||
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
|
||||
|
||||
const updateLocalKeywords = (next: Record<string, boolean>) => {
|
||||
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
|
||||
};
|
||||
|
||||
const onToggleStar = () => {
|
||||
if (!email) return;
|
||||
void toggleStar(email.id, !starred);
|
||||
setEmail({
|
||||
...email,
|
||||
keywords: {
|
||||
...email.keywords,
|
||||
...(starred ? {} : { $flagged: true }),
|
||||
},
|
||||
});
|
||||
const next = { ...email.keywords };
|
||||
if (starred) delete next.$flagged;
|
||||
else next.$flagged = true;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
|
||||
const onToggleUnread = () => {
|
||||
if (!email) return;
|
||||
if (unread) {
|
||||
void markRead(email.id);
|
||||
updateLocalKeywords({ ...email.keywords, $seen: true });
|
||||
} else {
|
||||
const next = { ...email.keywords };
|
||||
delete next.$seen;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
@@ -98,6 +160,35 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const onArchive = () => {
|
||||
if (!email || !currentMailboxId || !archiveMailbox) return;
|
||||
if (currentMailboxId === archiveMailbox.id) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const onToggleSpam = () => {
|
||||
if (!email || !currentMailboxId) return;
|
||||
setMoreMenuOpen(false);
|
||||
if (isInJunk) {
|
||||
const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId);
|
||||
if (!target) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, target.id);
|
||||
} else {
|
||||
if (!junkMailbox) return;
|
||||
void moveToMailbox(email.id, currentMailboxId, junkMailbox.id);
|
||||
}
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const onMoveToMailbox = (toId: string) => {
|
||||
if (!email || !currentMailboxId || toId === currentMailboxId) return;
|
||||
setMoveMenuOpen(false);
|
||||
setMoreMenuOpen(false);
|
||||
void moveToMailbox(email.id, currentMailboxId, toId);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const navigateCompose = (mode: 'reply' | 'replyAll' | 'forward') => {
|
||||
if (!email) return;
|
||||
const from = email.from?.[0];
|
||||
@@ -119,6 +210,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const from = email?.from?.[0];
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
|
||||
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
|
||||
// back arrow + side padding reserves ~64px. Drop optional buttons on narrow screens.
|
||||
const showMarkUnread = windowWidth >= 340;
|
||||
const showArchive = windowWidth >= 400 && !!archiveMailbox;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Toolbar */}
|
||||
@@ -132,6 +228,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
label="Delete"
|
||||
onPress={onDelete}
|
||||
/>
|
||||
{showArchive && (
|
||||
<ToolbarButton
|
||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
||||
label="Archive"
|
||||
onPress={onArchive}
|
||||
/>
|
||||
)}
|
||||
{showMarkUnread && (
|
||||
<ToolbarButton
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpen size={18} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Mail size={18} color={colors.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={unread ? 'Read' : 'Unread'}
|
||||
onPress={onToggleUnread}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<Star
|
||||
@@ -146,6 +262,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<ToolbarButton
|
||||
icon={<MoreVertical size={18} color={colors.textSecondary} />}
|
||||
label="More"
|
||||
onPress={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -203,7 +320,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '—'}
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -237,7 +354,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<BottomBarButton
|
||||
icon={<ChevronLeft size={20} color={colors.textMuted} />}
|
||||
label="Prev"
|
||||
disabled
|
||||
onPress={prevEmail ? () => goToEmail(prevEmail) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Reply size={20} color={colors.textSecondary} />}
|
||||
@@ -257,15 +375,249 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<BottomBarButton
|
||||
icon={<ChevronRight size={20} color={colors.textMuted} />}
|
||||
label="Next"
|
||||
disabled
|
||||
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
|
||||
disabled={!nextEmail}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<MoreMenuSheet
|
||||
visible={moreMenuOpen}
|
||||
onClose={() => setMoreMenuOpen(false)}
|
||||
unread={unread}
|
||||
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
|
||||
canMarkUnread={!showMarkUnread}
|
||||
canMove={mailboxes.length > 0}
|
||||
showSpam={!!junkMailbox || isInJunk}
|
||||
isInJunk={isInJunk}
|
||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
|
||||
onToggleSpam={onToggleSpam}
|
||||
/>
|
||||
|
||||
<MoveMenuSheet
|
||||
visible={moveMenuOpen}
|
||||
onClose={() => setMoveMenuOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={onMoveToMailbox}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
unread: boolean;
|
||||
canArchive: boolean;
|
||||
canMarkUnread: boolean;
|
||||
canMove: boolean;
|
||||
showSpam: boolean;
|
||||
isInJunk: boolean;
|
||||
onArchive: () => void;
|
||||
onToggleUnread: () => void;
|
||||
onMove: () => void;
|
||||
onToggleSpam: () => void;
|
||||
}
|
||||
|
||||
function MoreMenuSheet({
|
||||
visible, onClose, unread, canArchive, canMarkUnread, canMove,
|
||||
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
|
||||
}: MoreMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(400)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
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: 400, 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 style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>More actions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{canArchive && (
|
||||
<MoreMenuItem
|
||||
icon={<Archive size={18} color={colors.textSecondary} />}
|
||||
label="Archive"
|
||||
onPress={onArchive}
|
||||
/>
|
||||
)}
|
||||
{canMarkUnread && (
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
unread ? (
|
||||
<MailOpen size={18} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Mail size={18} color={colors.textSecondary} />
|
||||
)
|
||||
}
|
||||
label={unread ? 'Mark as read' : 'Mark as unread'}
|
||||
onPress={onToggleUnread}
|
||||
/>
|
||||
)}
|
||||
{canMove && (
|
||||
<MoreMenuItem
|
||||
icon={<FolderInput size={18} color={colors.textSecondary} />}
|
||||
label="Move to folder…"
|
||||
onPress={onMove}
|
||||
trailing={<ChevronRight size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{showSpam && (
|
||||
<MoreMenuItem
|
||||
icon={
|
||||
isInJunk ? (
|
||||
<ShieldCheck size={18} color={colors.success} />
|
||||
) : (
|
||||
<ShieldAlert size={18} color={colors.error} />
|
||||
)
|
||||
}
|
||||
label={isInJunk ? 'Not spam' : 'Mark as spam'}
|
||||
onPress={onToggleSpam}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoveMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
mailboxes: Mailbox[];
|
||||
currentMailboxId: string | null;
|
||||
onPick: (id: string) => void;
|
||||
}
|
||||
|
||||
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
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]);
|
||||
|
||||
const visibleNodes = React.useMemo(() => {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const expanded = new Set<string>();
|
||||
const collect = (nodes: MailboxNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.children.length > 0) {
|
||||
expanded.add(n.id);
|
||||
collect(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(tree);
|
||||
return flattenVisible(tree, expanded);
|
||||
}, [mailboxes]);
|
||||
|
||||
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,
|
||||
styles.sheetTall,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={node.id}
|
||||
onPress={canTarget ? () => onPick(node.id) : undefined}
|
||||
disabled={!canTarget}
|
||||
style={({ pressed }) => [
|
||||
styles.moveRow,
|
||||
pressed && canTarget && styles.moveRowPressed,
|
||||
{ paddingLeft: spacing.lg + node.depth * 16 },
|
||||
]}
|
||||
>
|
||||
<Icon size={16} color={canTarget ? colors.textSecondary : colors.textMuted} />
|
||||
<Text
|
||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreMenuItem({
|
||||
icon, label, onPress, trailing,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.moreItem, pressed && styles.moreItemPressed]}
|
||||
>
|
||||
<View style={styles.moreItemIcon}>{icon}</View>
|
||||
<Text style={styles.moreItemLabel}>{label}</Text>
|
||||
{trailing}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
icon, label, onPress,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
||||
@@ -324,7 +676,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minHeight: 44,
|
||||
},
|
||||
@@ -487,4 +839,94 @@ const styles = StyleSheet.create({
|
||||
bottomBarLabelDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
|
||||
// Bottom sheet (More menu / Move folder picker)
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: colors.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetTall: {
|
||||
maxHeight: '75%',
|
||||
},
|
||||
sheetHandle: {
|
||||
alignSelf: 'center',
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: colors.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
|
||||
// More menu item
|
||||
moreItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 48,
|
||||
},
|
||||
moreItemPressed: { backgroundColor: colors.surfaceHover },
|
||||
moreItemIcon: {
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
moreItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Move folder row
|
||||
moveRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
moveRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
moveRowLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
moveRowLabelDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
+352
-107
@@ -1,143 +1,388 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
||||
import { Settings, User, Palette, Bell, Shield, ChevronRight } from 'lucide-react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, BackHandler } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { ReadingSettings } from '../components/settings/ReadingSettings';
|
||||
import { NotificationSettings } from '../components/settings/NotificationSettings';
|
||||
import { AccountSettings } from '../components/settings/AccountSettings';
|
||||
import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AboutDataSettings } from '../components/settings/AboutDataSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
interface SettingItemProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
rightElement?: React.ReactNode;
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
| 'appearance' | 'layout'
|
||||
| 'reading' | 'composing' | 'identities' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'security' | 'encryption' | 'content_senders'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
experimental?: boolean;
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
function SettingItem({ icon, title, subtitle, rightElement }: SettingItemProps) {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => [styles.settingRow, pressed && styles.settingRowPressed]}>
|
||||
<View style={styles.settingIcon}>{icon}</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.settingSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
{rightElement || <ChevronRight size={18} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
mail: 'Mail',
|
||||
privacy: 'Privacy & Security',
|
||||
apps: 'Apps',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
// General
|
||||
{ id: 'account', label: 'Account', icon: User, group: 'general', implemented: true },
|
||||
{ id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: false },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true },
|
||||
|
||||
// Appearance
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: false },
|
||||
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: false },
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
|
||||
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: false },
|
||||
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
|
||||
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
|
||||
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: false },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: false },
|
||||
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: false },
|
||||
|
||||
// Privacy & Security
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: false },
|
||||
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: false },
|
||||
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: false },
|
||||
|
||||
// Apps
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
|
||||
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
|
||||
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
|
||||
// Advanced
|
||||
{ id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true },
|
||||
{ id: 'debug', label: 'Debug', icon: Bug, group: 'advanced', implemented: false },
|
||||
];
|
||||
|
||||
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
account: AccountSettings,
|
||||
notifications: NotificationSettings,
|
||||
reading: ReadingSettings,
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
about_data: AboutDataSettings,
|
||||
updates: UpdatesSettings,
|
||||
};
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
onBack?: () => void;
|
||||
onTabSelect?: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||
const groupedTabs = groupTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTab) return;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
setSelectedTab(null);
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [selectedTab]);
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
|
||||
if (selectedTab) {
|
||||
const tabDef = TABS.find(t => t.id === selectedTab)!;
|
||||
const Component = TAB_COMPONENTS[selectedTab];
|
||||
const TabIcon = tabDef.icon;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSelectedTab(null)}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<TabIcon size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabDef.label}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
{Component ? <Component /> : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Settings size={24} color={colors.primary} />
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Settings size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.profileAvatar}>
|
||||
<Text style={styles.profileAvatarText}>B</Text>
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<View key={group.group}>
|
||||
{groupIndex > 0 && <View style={styles.groupDivider} />}
|
||||
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
</View>
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => handleTabPress(tab)}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.tabItem,
|
||||
disabled && styles.tabItemDisabled,
|
||||
!disabled && pressed && styles.tabItemPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={colors.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
disabled && styles.tabItemLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{tab.experimental && !disabled && (
|
||||
<View style={styles.experimentalBadge}>
|
||||
<Text style={styles.experimentalText}>Experimental</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>Bulwark User</Text>
|
||||
<Text style={styles.profileEmail}>user@example.com</Text>
|
||||
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<SettingItem
|
||||
icon={<User size={20} color={colors.primary} />}
|
||||
title="Account Settings"
|
||||
subtitle="Manage your account details"
|
||||
/>
|
||||
<SettingItem
|
||||
icon={<Shield size={20} color={colors.primary} />}
|
||||
title="Privacy & Security"
|
||||
subtitle="Passwords, encryption, trusted senders"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Preferences</Text>
|
||||
<SettingItem
|
||||
icon={<Palette size={20} color={colors.primary} />}
|
||||
title="Dark Mode"
|
||||
rightElement={<Switch value={darkMode} onValueChange={setDarkMode} trackColor={{ true: colors.primary }} />}
|
||||
/>
|
||||
<SettingItem
|
||||
icon={<Bell size={20} color={colors.primary} />}
|
||||
title="Notifications"
|
||||
rightElement={<Switch value={notifications} onValueChange={setNotifications} trackColor={{ true: colors.primary }} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
|
||||
<Text style={styles.footerText}>Built with Expo + React Native</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header - webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: 8,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: { fontSize: 20, fontWeight: '700', color: colors.text },
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
margin: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
},
|
||||
profileAvatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary,
|
||||
headerBackBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
profileAvatarText: { fontSize: 24, fontWeight: '700', color: colors.textInverse },
|
||||
profileInfo: { flex: 1 },
|
||||
profileName: { fontSize: 18, fontWeight: '600', color: colors.text },
|
||||
profileEmail: { fontSize: 14, color: colors.textSecondary, marginTop: 2 },
|
||||
section: { marginTop: 8 },
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
headerBackBtnPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
scrollArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
detailContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
tabList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider - webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header - webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.8,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
settingRow: {
|
||||
|
||||
// Tab item - webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
settingIcon: { marginRight: 14 },
|
||||
settingContent: { flex: 1 },
|
||||
settingTitle: { fontSize: 16, color: colors.text },
|
||||
settingSubtitle: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
|
||||
footer: {
|
||||
tabItemPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 4,
|
||||
gap: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
tabItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge - webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
experimentalText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
tabItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
tabItemLabelDisabled: {
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notWorkingBadge: {
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout - webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
logoutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
},
|
||||
footerText: { fontSize: 12, color: colors.textMuted },
|
||||
});
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Switch } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
User, Mail, CalendarDays, Shield, Bell, Palette, Globe, Lock,
|
||||
ChevronRight, LogOut, Moon, Server, Key, Fingerprint, Info, HelpCircle
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
|
||||
interface SettingItem {
|
||||
id: string;
|
||||
icon: any;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
type: 'link' | 'toggle';
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
const PROFILE = {
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@bulwark.mail',
|
||||
plan: 'Pro',
|
||||
};
|
||||
|
||||
const SECTIONS: { title: string; items: SettingItem[] }[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{ id: 'profile', icon: User, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Profile', detail: PROFILE.email, type: 'link' },
|
||||
{ id: 'identities', icon: Mail, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Identities & Signatures', type: 'link' },
|
||||
{ id: 'security', icon: Lock, iconColor: colors.error, iconBg: colors.errorBg, label: 'Security', detail: '2FA enabled', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
items: [
|
||||
{ id: 'filters', icon: Mail, iconColor: colors.calendar.teal, iconBg: colors.calendar.tealBg, label: 'Filters & Rules', type: 'link' },
|
||||
{ id: 'smime', icon: Shield, iconColor: colors.calendar.indigo, iconBg: colors.calendar.indigoBg, label: 'S/MIME Encryption', type: 'link' },
|
||||
{ id: 'vacation', icon: CalendarDays, iconColor: colors.calendar.orange, iconBg: colors.warningBg, label: 'Vacation Responder', detail: 'Off', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
items: [
|
||||
{ id: 'theme', icon: Palette, iconColor: colors.calendar.pink, iconBg: colors.calendar.pinkBg, label: 'Theme', detail: 'System', type: 'link' },
|
||||
{ id: 'darkMode', icon: Moon, iconColor: colors.calendar.purple, iconBg: colors.calendar.purpleBg, label: 'Dark Mode', type: 'toggle', value: false },
|
||||
{ id: 'language', icon: Globe, iconColor: colors.calendar.blue, iconBg: colors.primaryBg, label: 'Language', detail: 'English', type: 'link' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
items: [
|
||||
{ id: 'pushNotif', icon: Bell, iconColor: colors.calendar.green, iconBg: colors.successBg, label: 'Push Notifications', type: 'toggle', value: true },
|
||||
{ id: 'emailNotif', icon: Mail, iconColor: colors.primary, iconBg: colors.primaryBg, label: 'Email Notifications', type: 'toggle', value: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Advanced',
|
||||
items: [
|
||||
{ id: 'server', icon: Server, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Server Settings', type: 'link' },
|
||||
{ id: 'privacy', icon: Fingerprint, iconColor: colors.textSecondary, iconBg: colors.surface, label: 'Privacy', type: 'link' },
|
||||
{ id: 'about', icon: Info, iconColor: colors.textMuted, iconBg: colors.surface, label: 'About', detail: 'v1.0.0', type: 'link' },
|
||||
{ id: 'help', icon: HelpCircle, iconColor: colors.textMuted, iconBg: colors.surface, label: 'Help & Support', type: 'link' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SettingRow({ item }: { item: SettingItem }) {
|
||||
const Icon = item.icon;
|
||||
const [toggled, setToggled] = React.useState(item.value ?? false);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.settingRow, pressed && item.type === 'link' && styles.settingRowPressed]}
|
||||
>
|
||||
<View style={[styles.settingIcon, { backgroundColor: item.iconBg }]}>
|
||||
<Icon size={18} color={item.iconColor} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={styles.settingLabel}>{item.label}</Text>
|
||||
{item.detail && item.type === 'link' && (
|
||||
<Text style={styles.settingDetail}>{item.detail}</Text>
|
||||
)}
|
||||
</View>
|
||||
{item.type === 'link' && (
|
||||
<ChevronRight size={18} color={colors.textMuted} />
|
||||
)}
|
||||
{item.type === 'toggle' && (
|
||||
<Switch
|
||||
value={toggled}
|
||||
onValueChange={setToggled}
|
||||
trackColor={{ false: colors.surface, true: colors.primary }}
|
||||
thumbColor={colors.background}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout }: SettingsScreenProps) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{/* Profile card */}
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.profileAvatar}>
|
||||
<Text style={styles.profileAvatarText}>JD</Text>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>{PROFILE.name}</Text>
|
||||
<Text style={styles.profileEmail}>{PROFILE.email}</Text>
|
||||
</View>
|
||||
<View style={styles.planBadge}>
|
||||
<Text style={styles.planBadgeText}>{PROFILE.plan}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Settings sections */}
|
||||
{SECTIONS.map(section => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.sectionCard}>
|
||||
{section.items.map((item, idx) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{idx > 0 && <View style={styles.sectionSeparator} />}
|
||||
<SettingRow item={item} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Sign out */}
|
||||
<Pressable style={styles.signOutBtn} onPress={onLogout}>
|
||||
<LogOut size={18} color={colors.error} />
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Version info */}
|
||||
<Text style={styles.versionText}>Bulwark Mobile v0.0.1</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
scrollContent: { paddingBottom: 40 },
|
||||
|
||||
// Profile card
|
||||
profileCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: spacing.lg,
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.xl,
|
||||
gap: spacing.md,
|
||||
},
|
||||
profileAvatar: {
|
||||
width: componentSizes.avatarLg, height: componentSizes.avatarLg,
|
||||
borderRadius: componentSizes.avatarLg / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
profileAvatarText: { ...typography.h3, color: colors.textInverse },
|
||||
profileInfo: { flex: 1 },
|
||||
profileName: { ...typography.bodyMedium, color: colors.text },
|
||||
profileEmail: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
|
||||
planBadge: {
|
||||
backgroundColor: colors.primaryBg,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
planBadgeText: { ...typography.small, color: colors.primary },
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
sectionCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionSeparator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderLight,
|
||||
marginLeft: 56,
|
||||
},
|
||||
|
||||
// Setting row
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
settingRowPressed: { backgroundColor: colors.surfaceHover },
|
||||
settingIcon: {
|
||||
width: 32, height: 32,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
settingContent: { flex: 1 },
|
||||
settingLabel: { ...typography.body, color: colors.text },
|
||||
settingDetail: { ...typography.small, color: colors.textMuted, marginTop: 1 },
|
||||
|
||||
// Sign out
|
||||
signOutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
backgroundColor: colors.errorBg,
|
||||
},
|
||||
signOutText: { ...typography.bodyMedium, color: colors.error },
|
||||
|
||||
// Version
|
||||
versionText: {
|
||||
...typography.small,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -1,359 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
ArrowLeft, LogOut, Settings, ChevronRight,
|
||||
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
|
||||
import { EmailSettings } from '../components/settings/EmailSettings';
|
||||
import { NotificationSettings } from '../components/settings/NotificationSettings';
|
||||
import { AccountSettings } from '../components/settings/AccountSettings';
|
||||
import { IdentitySettings } from '../components/settings/IdentitySettings';
|
||||
import { VacationSettings } from '../components/settings/VacationSettings';
|
||||
import { FolderSettings } from '../components/settings/FolderSettings';
|
||||
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
|
||||
import { UpdatesSettings } from '../components/settings/UpdatesSettings';
|
||||
|
||||
type Tab =
|
||||
| 'appearance' | 'email' | 'notifications'
|
||||
| 'account' | 'security' | 'identities' | 'encryption' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'themes' | 'plugins' | 'updates' | 'advanced';
|
||||
|
||||
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
|
||||
|
||||
interface TabDef {
|
||||
id: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: TabGroup;
|
||||
experimental?: boolean;
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<TabGroup, string> = {
|
||||
general: 'General',
|
||||
account: 'Account',
|
||||
organization: 'Organization',
|
||||
apps: 'Apps',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general', implemented: false },
|
||||
{ id: 'email', label: 'Email', icon: Mail, group: 'general', implemented: true },
|
||||
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general', implemented: true },
|
||||
{ id: 'account', label: 'Account', icon: User, group: 'account', implemented: true },
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'account', implemented: false },
|
||||
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account', implemented: true },
|
||||
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account', implemented: false },
|
||||
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account', implemented: true },
|
||||
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization', implemented: false },
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization', implemented: false },
|
||||
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization', implemented: true },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization', implemented: false },
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
|
||||
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
|
||||
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
|
||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
|
||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
|
||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'system', implemented: true },
|
||||
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
|
||||
];
|
||||
|
||||
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
email: EmailSettings,
|
||||
notifications: NotificationSettings,
|
||||
account: AccountSettings,
|
||||
identities: IdentitySettings,
|
||||
vacation: VacationSettings,
|
||||
folders: FolderSettings,
|
||||
updates: UpdatesSettings,
|
||||
advanced: AdvancedSettings,
|
||||
};
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
interface SettingsScreenProps {
|
||||
onLogout?: () => void;
|
||||
onBack?: () => void;
|
||||
onTabSelect?: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
|
||||
const groupedTabs = groupTabs();
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
|
||||
if (selectedTab) {
|
||||
const tabDef = TABS.find(t => t.id === selectedTab)!;
|
||||
const Component = TAB_COMPONENTS[selectedTab];
|
||||
const TabIcon = tabDef.icon;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={() => setSelectedTab(null)}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<TabIcon size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabDef.label}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
{Component ? <Component /> : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={colors.text} />
|
||||
</Pressable>
|
||||
<Settings size={20} color={colors.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Settings</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab list — matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.tabList}>
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<View key={group.group}>
|
||||
{groupIndex > 0 && <View style={styles.groupDivider} />}
|
||||
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
</View>
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => handleTabPress(tab)}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.tabItem,
|
||||
disabled && styles.tabItemDisabled,
|
||||
!disabled && pressed && styles.tabItemPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={colors.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
disabled && styles.tabItemLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{tab.experimental && !disabled && (
|
||||
<View style={styles.experimentalBadge}>
|
||||
<Text style={styles.experimentalText}>Experimental</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Logout — matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={onLogout}
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={colors.error} />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
|
||||
// Header — webmail: h-14, px-4, border-b, flex items-center gap-2
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBackBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerBackBtnPressed: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.h3,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
scrollArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
detailContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
tabList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group divider — webmail: mx-5 my-2 border-t border-border
|
||||
groupDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
|
||||
// Group header — webmail: px-5 pt-3 pb-1.5
|
||||
groupHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Tab item — webmail: px-5 py-3.5, icon w-4 h-4 text-muted-foreground, gap-3, ChevronRight
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
tabItemPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
tabItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
tabItemLabel: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
|
||||
// Experimental badge — webmail: text-[10px] rounded-full bg-warning/15 text-warning
|
||||
experimentalBadge: {
|
||||
backgroundColor: 'rgba(202, 138, 4, 0.15)',
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
experimentalText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.warning,
|
||||
},
|
||||
|
||||
tabItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
tabItemLabelDisabled: {
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
notWorkingBadge: {
|
||||
backgroundColor: colors.muted,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
notWorkingText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: colors.mutedForeground,
|
||||
},
|
||||
|
||||
// Logout — webmail: border-t border-border, px-5 py-3, destructive text
|
||||
logoutSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
logoutBtnPressed: {
|
||||
backgroundColor: colors.muted,
|
||||
},
|
||||
logoutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
},
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (serverUrl, username, password, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Adding an additional account — snapshot/reset feature stores so the
|
||||
// Adding an additional account - snapshot/reset feature stores so the
|
||||
// new account starts with a clean slate.
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
@@ -221,7 +221,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing — evict stale entry and surface error
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
@@ -250,7 +250,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
// Wait for persisted caches to finish hydrating from AsyncStorage.
|
||||
// Otherwise we read empty defaults and bounce the user back to the
|
||||
// login screen — and the feature stores don't have their cached data
|
||||
// login screen - and the feature stores don't have their cached data
|
||||
// yet when refetchFeatureStores() checks currentMailboxId / loadedRange
|
||||
// at the end of this function.
|
||||
await Promise.all([
|
||||
|
||||
@@ -122,7 +122,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before };
|
||||
const ids = calendars.map((c) => c.id);
|
||||
if (ids.length === 0) {
|
||||
// Calendars haven't loaded yet — fetch them, then events.
|
||||
// Calendars haven't loaded yet - fetch them, then events.
|
||||
await get().fetchCalendars();
|
||||
}
|
||||
const calendarIdsAfter = get().calendars.map((c) => c.id);
|
||||
|
||||
@@ -114,7 +114,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
selectMailbox: async (mailboxId) => {
|
||||
// Reset search/filters when switching mailbox — matches webmail behavior.
|
||||
// Reset search/filters when switching mailbox - matches webmail behavior.
|
||||
set({
|
||||
currentMailboxId: mailboxId,
|
||||
loading: true,
|
||||
@@ -157,7 +157,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters } = get();
|
||||
if (!currentMailboxId) return;
|
||||
// Keep existing emails visible while refreshing — swap atomically once
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
// Qui Dark Theme — exact values from Bulwark Webmail's builtin-themes.ts
|
||||
// Qui Dark Theme - exact values from Bulwark Webmail's builtin-themes.ts
|
||||
export const colors = {
|
||||
// Primary brand (Qui theme)
|
||||
primary: '#3b82f6',
|
||||
@@ -69,7 +69,7 @@ export const colors = {
|
||||
orangeBg: 'rgba(251, 191, 36, 0.08)', // orange-950/30 dark
|
||||
},
|
||||
|
||||
// Tag colors — from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// Tag colors - from KEYWORD_PALETTE in stores/settings-store.ts
|
||||
// dark mode: bg-{color}-950/30, dot: bg-{color}-500, text: {color}-400
|
||||
tags: {
|
||||
red: { dot: '#ef4444', bg: 'rgba(69,10,10,0.30)', text: '#f87171' },
|
||||
@@ -87,7 +87,7 @@ export const colors = {
|
||||
gray: { dot: '#6b7280', bg: 'rgba(3,7,18,0.30)', text: '#9ca3af' },
|
||||
},
|
||||
|
||||
// Navigation — matches web nav-rail
|
||||
// Navigation - matches web nav-rail
|
||||
navActive: '#3b82f6',
|
||||
navInactive: '#71717a', // --color-muted-foreground
|
||||
navBadge: '#ef4444', // red-500
|
||||
@@ -145,18 +145,18 @@ export const typography = {
|
||||
h1: { fontSize: 24, fontWeight: '700' as const, lineHeight: 32 }, // text-2xl
|
||||
h2: { fontSize: 20, fontWeight: '700' as const, lineHeight: 28 }, // text-xl
|
||||
h3: { fontSize: 18, fontWeight: '600' as const, lineHeight: 28 }, // text-lg
|
||||
// Body = text-sm (14px) — the web uses text-sm for almost all content
|
||||
// Body = text-sm (14px) - the web uses text-sm for almost all content
|
||||
body: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
|
||||
bodyMedium: { fontSize: 14, fontWeight: '500' as const, lineHeight: 20 },
|
||||
bodySemibold: { fontSize: 14, fontWeight: '600' as const, lineHeight: 20 },
|
||||
bodyBold: { fontSize: 14, fontWeight: '700' as const, lineHeight: 20 },
|
||||
// Base = text-base (16px) — used for email content body
|
||||
// Base = text-base (16px) - used for email content body
|
||||
base: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
|
||||
baseMedium: { fontSize: 16, fontWeight: '500' as const, lineHeight: 24 },
|
||||
// Caption = text-xs (12px)
|
||||
caption: { fontSize: 12, fontWeight: '400' as const, lineHeight: 16 },
|
||||
captionMedium: { fontSize: 12, fontWeight: '500' as const, lineHeight: 16 },
|
||||
// Small = 10px — for tags, badges, tab labels
|
||||
// Small = 10px - for tags, badges, tab labels
|
||||
small: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
tabLabel: { fontSize: 10, fontWeight: '500' as const, lineHeight: 14 },
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user