From 2a3b1f90dc846f67c55afa8f44ffcdd313141e66 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 10:24:36 +0200 Subject: [PATCH] feat: add upload, preview, download, selection, folder creation --- package-lock.json | 10 + package.json | 1 + src/api/files.ts | 79 ++++- src/api/types.ts | 3 +- src/screens/FilesScreen.tsx | 626 ++++++++++++++++++++++++++++++++---- 5 files changed, 651 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55fadadc..a914a1a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@react-navigation/native-stack": "^7.14.10", "date-fns": "^4.1.0", "expo": "~54.0.33", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-image-picker": "~17.0.10", "expo-intent-launcher": "~13.0.8", @@ -5216,6 +5217,15 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", diff --git a/package.json b/package.json index c29ab800..7e1ed490 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@react-navigation/native-stack": "^7.14.10", "date-fns": "^4.1.0", "expo": "~54.0.33", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-image-picker": "~17.0.10", "expo-intent-launcher": "~13.0.8", diff --git a/src/api/files.ts b/src/api/files.ts index 40121a8f..9ab8144e 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,19 +1,35 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { FileNode } from './types'; +import { getDownloadUrl, uploadBlob } from './blob'; -const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL]; +const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES]; -export async function getFileNodes(parentId?: string | null): Promise { +// Stalwart and other JMAP servers use one of these strings on FileNode.type to +// mark a directory; everything else is a MIME type. +const DIRECTORY_TYPES = new Set([ + 'd', + 'application/x-directory', + 'text/directory', + 'httpd/unix-directory', + 'inode/directory', +]); + +export function isDirectory(node: Pick): boolean { + return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory'); +} + +// Stalwart's FileNode/get does not support a parentId filter — fetching with +// `ids: null` returns the whole tree, which the caller filters client-side. +// Mirrors the webmail file-store; keeping the API uniform avoids a divergence +// later when we add other JMAP servers. +export async function getAllFileNodes(): Promise { const accountId = jmapClient.accountId; - const filter: Record = {}; - if (parentId !== undefined) filter.parentId = parentId; - const res = await jmapClient.request( - [['FileNode/get', { accountId, filter }, '0']], + [['FileNode/get', { accountId, ids: null }, '0']], USING, ); - return res.methodResponses[0][1].list; + return (res.methodResponses[0][1].list ?? []) as FileNode[]; } export async function createFileNode( @@ -27,13 +43,60 @@ export async function createFileNode( }, '0']], USING, ); - return res.methodResponses[0][1].created['new-file']; + const created = res.methodResponses[0][1].created?.['new-file']; + if (!created) { + const err = res.methodResponses[0][1].notCreated?.['new-file']; + throw new Error(err?.description || 'Create failed'); + } + return created; +} + +export async function createFolder( + name: string, + parentId: string | null, +): Promise { + return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 }); +} + +export async function updateFileNode( + id: string, + updates: Partial>, +): Promise { + const accountId = jmapClient.accountId; + const res = await jmapClient.request( + [['FileNode/set', { accountId, update: { [id]: updates } }, '0']], + USING, + ); + const notUpdated = res.methodResponses[0][1].notUpdated?.[id]; + if (notUpdated) throw new Error(notUpdated.description || 'Update failed'); } export async function deleteFileNodes(ids: string[]): Promise { + if (ids.length === 0) return; const accountId = jmapClient.accountId; await jmapClient.request( [['FileNode/set', { accountId, destroy: ids }, '0']], USING, ); } + +export function getFileNodeDownloadUrl(node: FileNode): string { + if (!node.blobId) throw new Error('Folders cannot be downloaded'); + return getDownloadUrl(node.blobId, node.name, node.type); +} + +export async function uploadFileNode( + uri: string, + name: string, + mimeType: string, + parentId: string | null, +): Promise { + const blob = await uploadBlob(uri, mimeType); + return createFileNode({ + name, + parentId, + type: blob.type || mimeType || 'application/octet-stream', + blobId: blob.blobId, + size: blob.size, + }); +} diff --git a/src/api/types.ts b/src/api/types.ts index 6f62bbd8..81fc1cbf 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -429,7 +429,7 @@ export interface FileNode { name: string; parentId?: string | null; type: string; - blobId?: string; + blobId?: string | null; size?: number; created?: string; updated?: string; @@ -465,4 +465,5 @@ export const CAPABILITIES = { CALENDARS: 'urn:ietf:params:jmap:calendars', SIEVE: 'urn:ietf:params:jmap:sieve', QUOTA: 'urn:ietf:params:jmap:quota', + FILES: 'urn:ietf:params:jmap:filenode', } as const; diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 4c442a75..ecbd8908 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -1,23 +1,34 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, + Alert, FlatList, + Modal, Pressable, RefreshControl, StyleSheet, Text, + TextInput, TouchableOpacity, + TouchableWithoutFeedback, View, } from 'react-native'; import { - ChevronLeft, FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio, - FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, + ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo, + FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, + MoreVertical, Pencil, Share2, Trash2, Upload, X, Download, } from 'lucide-react-native'; -import { getFileNodes } from '../api/files'; +import * as DocumentPicker from 'expo-document-picker'; +import { + createFolder, deleteFileNodes, getAllFileNodes, isDirectory, updateFileNode, + uploadFileNode, +} from '../api/files'; +import { downloadAttachment, shareAttachment } from '../lib/email-export'; import type { FileNode } from '../api/types'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useSettingsStore, type FilesViewMode } from '../stores/settings-store'; +import Dialog from '../components/Dialog'; interface FolderFrame { id: string | null; @@ -82,12 +93,24 @@ function formatModified(iso?: string): string { export default function FilesScreen() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const [stack, setStack] = useState([ROOT_FRAME]); - const [files, setFiles] = useState([]); + const [allNodes, setAllNodes] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [selection, setSelection] = useState>(new Set()); + const [busyId, setBusyId] = useState(null); + const [uploading, setUploading] = useState(false); + + const [confirmDelete, setConfirmDelete] = useState<{ ids: string[] } | null>(null); + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [renameTarget, setRenameTarget] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [actionsTarget, setActionsTarget] = useState(null); + const showIcons = useSettingsStore((s) => s.filesShowIcons); const coloredIcons = useSettingsStore((s) => s.filesColoredIcons); const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles); @@ -96,21 +119,20 @@ export default function FilesScreen() { const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode); const setSetting = useSettingsStore((s) => s.updateSetting); - // Local override so the user can flip view from the toolbar without - // changing their persisted default. Falls back to the setting. const [viewOverride, setViewOverride] = useState(null); const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; const current = stack[stack.length - 1]; + const selectionMode = selection.size > 0; const loadFiles = useCallback( - async (parentId: string | null, mode: 'initial' | 'refresh' = 'initial') => { + async (mode: 'initial' | 'refresh' = 'initial') => { if (mode === 'refresh') setRefreshing(true); else setLoading(true); setError(null); try { - const nodes = await getFileNodes(parentId); - setFiles(nodes); + const nodes = await getAllFileNodes(); + setAllNodes(nodes); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load files'); } finally { @@ -122,18 +144,31 @@ export default function FilesScreen() { ); useEffect(() => { - void loadFiles(current.id); - }, [loadFiles, current.id]); + void loadFiles(); + }, [loadFiles]); + + // If we navigated into a folder that has since been deleted, walk back up + // until we land on a still-existing one (or root). + useEffect(() => { + if (allNodes.length === 0) return; + setStack((prev) => { + const ids = new Set(allNodes.map((n) => n.id)); + let cut = prev.length; + for (let i = prev.length - 1; i > 0; i--) { + const id = prev[i].id; + if (id != null && !ids.has(id)) cut = i; + } + return cut === prev.length ? prev : prev.slice(0, cut); + }); + }, [allNodes]); const visibleFiles = useMemo(() => { - let out = files; + let out = allNodes.filter((n) => (n.parentId ?? null) === current.id); if (!showHiddenFiles) out = out.filter((f) => !isHidden(f)); - // Directories always sort to the top regardless of sort key, then secondary - // sort by the requested column. This matches macOS Finder / web file pickers. const dir = sortDir === 'desc' ? -1 : 1; return [...out].sort((a, b) => { - const aDir = a.type === 'directory' ? 0 : 1; - const bDir = b.type === 'directory' ? 0 : 1; + const aDir = isDirectory(a) ? 0 : 1; + const bDir = isDirectory(b) ? 0 : 1; if (aDir !== bDir) return aDir - bDir; switch (sortKey) { case 'size': @@ -148,19 +183,66 @@ export default function FilesScreen() { return a.name.localeCompare(b.name) * dir; } }); - }, [files, showHiddenFiles, sortKey, sortDir]); + }, [allNodes, current.id, showHiddenFiles, sortKey, sortDir]); - const openNode = useCallback((node: FileNode) => { - if (node.type === 'directory') { - setStack((s) => [...s, { id: node.id, name: node.name }]); - } - // Files are no-ops for now — opening a blob would need a download flow - // wired to the JMAP download endpoint. + const clearSelection = useCallback(() => setSelection(new Set()), []); + + const toggleSelect = useCallback((id: string) => { + setSelection((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); }, []); + const previewFile = useCallback(async (node: FileNode) => { + if (!node.blobId) return; + setBusyId(node.id); + try { + await shareAttachment(node.blobId, node.name, node.type); + } catch (e) { + Alert.alert('Preview failed', e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }, []); + + const downloadFile = useCallback(async (node: FileNode) => { + if (!node.blobId) return; + setBusyId(node.id); + try { + await downloadAttachment(node.blobId, node.name, node.type); + } catch (e) { + Alert.alert('Download failed', e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }, []); + + const handleRowPress = useCallback( + (node: FileNode) => { + if (selectionMode) { + toggleSelect(node.id); + return; + } + if (isDirectory(node)) { + setStack((s) => [...s, { id: node.id, name: node.name }]); + return; + } + void previewFile(node); + }, + [selectionMode, toggleSelect, previewFile], + ); + + const handleRowLongPress = useCallback((node: FileNode) => { + toggleSelect(node.id); + }, [toggleSelect]); + const goBack = useCallback(() => { setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); - }, []); + clearSelection(); + }, [clearSelection]); const toggleView = useCallback(() => { const next: FilesViewMode = viewMode === 'list' ? 'grid' : 'list'; @@ -169,12 +251,126 @@ export default function FilesScreen() { }, [viewMode, setSetting]); const onRefresh = useCallback(() => { - void loadFiles(current.id, 'refresh'); - }, [loadFiles, current.id]); + void loadFiles('refresh'); + }, [loadFiles]); + + const submitNewFolder = useCallback(async () => { + const name = newFolderName.trim(); + if (!name) return; + setNewFolderOpen(false); + setNewFolderName(''); + try { + await createFolder(name, current.id); + await loadFiles('refresh'); + } catch (e) { + Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e)); + } + }, [newFolderName, current.id, loadFiles]); + + const submitRename = useCallback(async () => { + if (!renameTarget) return; + const name = renameValue.trim(); + if (!name || name === renameTarget.name) { + setRenameTarget(null); + return; + } + const target = renameTarget; + setRenameTarget(null); + setRenameValue(''); + try { + await updateFileNode(target.id, { name }); + await loadFiles('refresh'); + } catch (e) { + Alert.alert('Rename failed', e instanceof Error ? e.message : String(e)); + } + }, [renameTarget, renameValue, loadFiles]); + + const startUpload = useCallback(async () => { + if (uploading) return; + let result; + try { + result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + }); + } catch (e) { + Alert.alert('Pick failed', e instanceof Error ? e.message : String(e)); + return; + } + if (result.canceled || result.assets.length === 0) return; + const asset = result.assets[0]; + setUploading(true); + try { + await uploadFileNode( + asset.uri, + asset.name, + asset.mimeType || 'application/octet-stream', + current.id, + ); + await loadFiles('refresh'); + } catch (e) { + Alert.alert('Upload failed', e instanceof Error ? e.message : String(e)); + } finally { + setUploading(false); + } + }, [uploading, current.id, loadFiles]); + + const requestDelete = useCallback((ids: string[]) => { + if (ids.length === 0) return; + setConfirmDelete({ ids }); + }, []); + + const performDelete = useCallback(async () => { + if (!confirmDelete) return; + // Cascade: if the user deletes a folder, also delete every descendant + // we know about. Stalwart deletes folders with content silently otherwise. + const targetIds = new Set(confirmDelete.ids); + let frontier = [...confirmDelete.ids]; + while (frontier.length > 0) { + const next: string[] = []; + for (const node of allNodes) { + if (node.parentId && frontier.includes(node.parentId) && !targetIds.has(node.id)) { + targetIds.add(node.id); + next.push(node.id); + } + } + frontier = next; + } + setConfirmDelete(null); + clearSelection(); + try { + await deleteFileNodes(Array.from(targetIds)); + await loadFiles('refresh'); + } catch (e) { + Alert.alert('Delete failed', e instanceof Error ? e.message : String(e)); + } + }, [confirmDelete, allNodes, clearSelection, loadFiles]); const renderHeader = () => { const canBack = stack.length > 1; const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon; + if (selectionMode) { + return ( + + + + + + {selection.size} selected + + requestDelete(Array.from(selection))} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel="Delete selected" + > + + + + + + ); + } return ( @@ -188,14 +384,37 @@ export default function FilesScreen() { {current.name} - - - + + setNewFolderOpen(true)} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel="New folder" + > + + + void startUpload()} + hitSlop={8} + style={styles.headerBtn} + accessibilityLabel="Upload file" + disabled={uploading} + > + {uploading ? ( + + ) : ( + + )} + + + + + {canBack ? ( @@ -207,18 +426,22 @@ export default function FilesScreen() { }; const renderRow = (item: FileNode) => { - const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name); - const tint = item.type === 'directory' + const isDir = isDirectory(item); + const Icon = isDir ? Folder : pickFileIcon(item.name); + const tint = isDir ? (coloredIcons ? c.calendar.blue : c.textMuted) : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); - const tappable = item.type === 'directory'; + const selected = selection.has(item.id); + const busy = busyId === item.id; return ( openNode(item)} - disabled={!tappable} + onPress={() => handleRowPress(item)} + onLongPress={() => handleRowLongPress(item)} + delayLongPress={300} style={({ pressed }) => [ styles.fileRow, - pressed && tappable && { backgroundColor: c.surfaceHover }, + selected && { backgroundColor: c.primaryBg }, + pressed && !selected && { backgroundColor: c.surfaceHover }, ]} > {showIcons && } @@ -227,7 +450,7 @@ export default function FilesScreen() { {item.name} - {item.size != null && item.type !== 'directory' ? ( + {item.size != null && !isDir ? ( {formatFileSize(item.size)} ) : null} {item.updated ? ( @@ -235,23 +458,40 @@ export default function FilesScreen() { ) : null} + {busy ? ( + + ) : !selectionMode ? ( + { + e.stopPropagation(); + setActionsTarget(item); + }} + hitSlop={8} + style={styles.rowMore} + > + + + ) : null} ); }; const renderGridCell = (item: FileNode) => { - const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name); - const tint = item.type === 'directory' + const isDir = isDirectory(item); + const Icon = isDir ? Folder : pickFileIcon(item.name); + const tint = isDir ? (coloredIcons ? c.calendar.blue : c.textMuted) : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); - const tappable = item.type === 'directory'; + const selected = selection.has(item.id); return ( openNode(item)} - disabled={!tappable} + onPress={() => handleRowPress(item)} + onLongPress={() => handleRowLongPress(item)} + delayLongPress={300} style={({ pressed }) => [ styles.gridItem, - pressed && tappable && { backgroundColor: c.surfaceHover }, + selected && { backgroundColor: c.primaryBg }, + pressed && !selected && { backgroundColor: c.surfaceHover }, ]} > {showIcons ? ( @@ -262,7 +502,7 @@ export default function FilesScreen() { {item.name} - {item.size != null && item.type !== 'directory' ? ( + {item.size != null && !isDir ? ( {formatFileSize(item.size)} @@ -284,7 +524,7 @@ export default function FilesScreen() { {error} void loadFiles(current.id)} + onPress={() => void loadFiles()} > Retry @@ -310,11 +550,7 @@ export default function FilesScreen() { contentContainerStyle={styles.gridContent} renderItem={({ item }) => renderGridCell(item)} refreshControl={ - + } /> ); @@ -326,11 +562,7 @@ export default function FilesScreen() { keyExtractor={(item) => item.id} renderItem={({ item }) => renderRow(item)} refreshControl={ - + } /> ); @@ -340,10 +572,181 @@ export default function FilesScreen() { {renderHeader()} {body} + + { + setNewFolderOpen(false); + setNewFolderName(''); + }} + onSubmit={submitNewFolder} + /> + + { + setRenameTarget(null); + setRenameValue(''); + }} + onSubmit={submitRename} + /> + + setActionsTarget(null)} + onPreview={(n) => { + setActionsTarget(null); + void previewFile(n); + }} + onDownload={(n) => { + setActionsTarget(null); + void downloadFile(n); + }} + onRename={(n) => { + setActionsTarget(null); + setRenameTarget(n); + setRenameValue(n.name); + }} + onDelete={(n) => { + setActionsTarget(null); + requestDelete([n.id]); + }} + /> + + 1 + ? `Delete ${confirmDelete.ids.length} items?` + : 'Delete this item?' + } + message="Folders are deleted along with everything inside them. This can't be undone." + variant="destructive" + confirmText="Delete" + onConfirm={() => void performDelete()} + onCancel={() => setConfirmDelete(null)} + /> ); } +interface PromptModalProps { + visible: boolean; + title: string; + placeholder: string; + confirmText: string; + value: string; + onChange: (v: string) => void; + onCancel: () => void; + onSubmit: () => void; +} + +function PromptModal(props: PromptModalProps) { + const c = useColors(); + const styles = React.useMemo(() => makePromptStyles(c), [c]); + return ( + + + + + + {props.title} + + + + Cancel + + + {props.confirmText} + + + + + + + + ); +} + +interface ActionsSheetProps { + target: FileNode | null; + onClose: () => void; + onPreview: (n: FileNode) => void; + onDownload: (n: FileNode) => void; + onRename: (n: FileNode) => void; + onDelete: (n: FileNode) => void; +} + +function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeSheetStyles(c), [c]); + if (!target) return null; + const isDir = isDirectory(target); + return ( + + + + + + + {target.name} + + {!isDir ? ( + onPreview(target)}> + + Preview / Share + + ) : null} + {!isDir ? ( + onDownload(target)}> + + Save to device + + ) : null} + onRename(target)}> + + Rename + + onDelete(target)}> + + Delete + + + + + + + ); +} + function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { @@ -369,6 +772,11 @@ function makeStyles(c: ThemePalette) { alignItems: 'center', gap: spacing.xs, }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, headerBtn: { padding: spacing.xs, borderRadius: radius.sm, @@ -437,6 +845,9 @@ function makeStyles(c: ThemePalette) { fontSize: typography.caption.fontSize, color: c.textMuted, }, + rowMore: { + padding: spacing.xs, + }, gridContent: { padding: spacing.sm, }, @@ -463,3 +874,100 @@ function makeStyles(c: ThemePalette) { }, }); } + +function makePromptStyles(c: ThemePalette) { + return StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + dialog: { + width: '100%', + maxWidth: 400, + backgroundColor: c.background, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.lg, + padding: spacing.lg, + gap: spacing.md, + }, + title: { + ...typography.h3, + color: c.text, + }, + input: { + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: c.text, + fontSize: typography.body.fontSize, + backgroundColor: c.surface, + }, + row: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + }, + btnGhost: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + }, + btnGhostText: { + color: c.text, + fontWeight: '600' as const, + }, + btnPrimary: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.md, + backgroundColor: c.primary, + }, + btnPrimaryText: { + color: c.primaryForeground, + fontWeight: '600' as const, + }, + }); +} + +function makeSheetStyles(c: ThemePalette) { + return StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: c.background, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xxl, + paddingHorizontal: spacing.md, + gap: spacing.xs, + }, + sheetTitle: { + ...typography.bodyMedium, + color: c.textMuted, + paddingHorizontal: spacing.sm, + paddingBottom: spacing.sm, + }, + action: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderRadius: radius.md, + }, + actionLabel: { + ...typography.body, + color: c.text, + }, + }); +}