diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index ddd229281c0..eea266a3c86 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -339,13 +339,25 @@ export default function HostScreen() { const handleDeleteWorktree = useCallback( async (item: Worktree) => { if (!client) return + + const removeFromList = (list: Worktree[]) => + list.filter((w) => w.worktreeId !== item.worktreeId) + setWorktrees(removeFromList) + setLastKnownWorktrees(removeFromList) + try { - await client.sendRequest('worktree.remove', { - worktree: `id:${item.worktreeId}` + const response = await client.sendRequest('worktree.rm', { + worktree: `id:${item.worktreeId}`, + force: true }) + if (!response.ok) { + setWorktrees((prev) => [...prev, item]) + setLastKnownWorktrees((prev) => [...prev, item]) + } void fetchWorktrees() } catch { - // Deletion failed — will be visible when list doesn't change + setWorktrees((prev) => [...prev, item]) + setLastKnownWorktrees((prev) => [...prev, item]) } }, [client, fetchWorktrees] @@ -687,7 +699,7 @@ export default function HostScreen() { {/* Worktree long-press action sheet */} { void handleDeleteWorktree(confirmDelete) setConfirmDelete(null) + setActionTarget(null) } } ] : [] } - onClose={() => setConfirmDelete(null)} + onClose={() => { + setConfirmDelete(null) + setActionTarget(null) + }} /> {/* Host remove confirmation */} diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index be01823fe65..e3c245f2794 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -12,6 +12,7 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' +import AsyncStorage from '@react-native-async-storage/async-storage' import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native' import { connect, type RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' @@ -24,6 +25,11 @@ import { import { StatusDot } from '../../../../src/components/StatusDot' import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { TextInputModal } from '../../../../src/components/TextInputModal' +import { + CustomKeyModal, + loadCustomKeys, + type CustomKey +} from '../../../../src/components/CustomKeyModal' import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' type Terminal = { @@ -49,7 +55,14 @@ const ACCESSORY_KEYS: AccessoryKey[] = [ { label: '←', bytes: '\x1b[D' }, { label: '→', bytes: '\x1b[C' }, { label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, - { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' } + { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } ] // Why: persists fitted-handle sets across component remounts (e.g. navigating @@ -121,6 +134,9 @@ export default function SessionScreen() { const [createError, setCreateError] = useState('') const [actionTarget, setActionTarget] = useState(null) const [renameTarget, setRenameTarget] = useState(null) + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + const [deleteKeyTarget, setDeleteKeyTarget] = useState(null) const [fittedHandles, setFittedHandles] = useState>( () => persistedFittedHandles.get(worktreeId!) ?? new Set() ) @@ -321,6 +337,19 @@ export default function SessionScreen() { } }, [clearTerminalCache, hostId]) + useEffect(() => { + void loadCustomKeys().then(setCustomKeys) + }, []) + + const handleDeleteCustomKey = useCallback( + async (key: CustomKey) => { + const updated = customKeys.filter((k) => k.id !== key.id) + setCustomKeys(updated) + await AsyncStorage.setItem('orca:custom-accessory-keys', JSON.stringify(updated)) + }, + [customKeys] + ) + useEffect(() => { clearTerminalCache() activeHandleRef.current = null @@ -563,7 +592,7 @@ export default function SessionScreen() { ) async function handleSend() { - if (!client || !activeHandle || !input.trim()) return + if (!client || !activeHandle) return const text = input setInput('') @@ -805,6 +834,32 @@ export default function SessionScreen() { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accessoryContent} > + [ + styles.accessoryKey, + pressed && styles.accessoryKeyPressed, + (!canSend || fitPending) && styles.accessoryKeyDisabled + ]} + disabled={!canSend || fitPending} + onPress={() => { + if (activeHandle && fittedHandles.has(activeHandle)) { + void handleRestoreDesktopSize() + } else { + void handleFitToPhone() + } + }} + accessibilityLabel={ + activeHandle && fittedHandles.has(activeHandle) + ? 'Restore desktop size' + : 'Fit to phone' + } + > + {activeHandle && fittedHandles.has(activeHandle) ? ( + + ) : ( + + )} + {ACCESSORY_KEYS.map((key) => ( ))} + {customKeys.map((key) => ( + [ + styles.accessoryKey, + styles.customAccessoryKey, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={() => void handleAccessoryKey(key.bytes)} + onLongPress={() => { + triggerMediumImpact() + setDeleteKeyTarget(key) + }} + delayLongPress={400} + accessibilityLabel={`Send ${key.label}`} + > + + {key.label} + + + ))} + [styles.accessoryKey, pressed && styles.accessoryKeyPressed]} + onPress={() => setShowCustomKeyModal(true)} + accessibilityLabel="Add custom shortcut" + > + + @@ -906,6 +991,29 @@ export default function SessionScreen() { onSubmit={(value) => void handleRenameTerminal(value)} onCancel={() => setRenameTarget(null)} /> + setShowCustomKeyModal(false)} + onKeysChanged={setCustomKeys} + /> + { + if (deleteKeyTarget) { + void handleDeleteCustomKey(deleteKeyTarget) + } + setDeleteKeyTarget(null) + } + } + ]} + onClose={() => setDeleteKeyTarget(null)} + /> ) } @@ -1073,6 +1181,10 @@ const styles = StyleSheet.create({ accessoryKeyPressed: { backgroundColor: colors.borderSubtle }, + customAccessoryKey: { + borderWidth: 1, + borderColor: colors.borderSubtle + }, accessoryKeyDisabled: { opacity: 0.35 }, diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx index 29fef376014..4a6a7f60a90 100644 --- a/mobile/src/components/ActionSheetModal.tsx +++ b/mobile/src/components/ActionSheetModal.tsx @@ -1,6 +1,6 @@ import { Modal, View, Text, Pressable, StyleSheet, Platform } from 'react-native' -import { Edit3, Trash2, X, type LucideIcon } from 'lucide-react-native' -import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' export type ActionSheetAction = { label: string @@ -25,54 +25,53 @@ function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { return ( - + - {}}> + - - - {title ? {title} : null} + + {(title || message) && ( + + {title ? ( + + {title} + + ) : null} {message ? {message} : null} - [styles.closeButton, pressed && styles.rowPressed]} - onPress={onClose} - accessibilityLabel="Close" - > - - - + )} - - {actions.map((action) => { + + {actions.map((action, i) => { const Icon = iconForAction(action.label, action.destructive, action.icon) return ( - [styles.action, pressed && styles.rowPressed]} - onPress={() => { - onClose() - action.onPress() - }} - > - + {i > 0 && } + [styles.action, pressed && styles.actionPressed]} + onPress={() => { + onClose() + action.onPress() + }} > - - - {action.label} - - + + {action.label} + + + ) })} - + ) @@ -81,97 +80,72 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }: const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'transparent', - justifyContent: 'flex-end', - paddingHorizontal: spacing.sm + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end' }, - sheet: { - width: '100%', - backgroundColor: colors.bgPanel, - borderTopLeftRadius: 18, - borderTopRightRadius: 18, - paddingTop: spacing.sm, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, paddingHorizontal: spacing.md, - paddingBottom: spacing.lg, - borderWidth: 1, - borderColor: colors.borderSubtle, + paddingBottom: spacing.xl + spacing.md, ...Platform.select({ ios: { shadowColor: '#000', - shadowOffset: { width: 0, height: -4 }, - shadowOpacity: 0.35, - shadowRadius: 18 + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 }, - android: { elevation: 12 } + android: { elevation: 8 } }) }, handle: { alignSelf: 'center', - width: 38, + width: 36, height: 4, borderRadius: 2, - backgroundColor: colors.borderSubtle, - marginBottom: spacing.md + backgroundColor: colors.textMuted, + marginTop: spacing.sm, + marginBottom: spacing.md, + opacity: 0.4 }, header: { - flexDirection: 'row', - alignItems: 'flex-start', - paddingBottom: spacing.md - }, - headerText: { - flex: 1, - minWidth: 0, - paddingRight: spacing.md + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm }, title: { - fontSize: typography.titleSize, - fontWeight: '700', - color: colors.textPrimary, - marginBottom: spacing.xs + fontSize: 13, + fontWeight: '500', + color: colors.textMuted }, message: { - fontSize: 13, - color: colors.textSecondary, - lineHeight: 18 + fontSize: 12, + color: colors.textMuted, + marginTop: 2 }, - closeButton: { - width: 34, - height: 34, - borderRadius: 17, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised - }, - actions: { - gap: spacing.sm - }, - action: { - minHeight: 54, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - borderRadius: radii.row, - backgroundColor: colors.bgBase, + actionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, overflow: 'hidden' }, - rowPressed: { - backgroundColor: colors.bgRaised + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md }, - actionIcon: { - width: 32, - height: 32, - borderRadius: 16, + action: { + flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised, - marginRight: spacing.md + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 }, - actionIconDestructive: { - backgroundColor: colors.statusRed + '18' + actionPressed: { + backgroundColor: colors.bgRaised }, actionText: { fontSize: typography.bodySize, - fontWeight: '600', + fontWeight: '500', color: colors.textPrimary }, actionTextDestructive: { diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx new file mode 100644 index 00000000000..6e6e9634b49 --- /dev/null +++ b/mobile/src/components/CustomKeyModal.tsx @@ -0,0 +1,383 @@ +import { useState, useEffect, useCallback } from 'react' +import { + Modal, + View, + Text, + Pressable, + TextInput, + StyleSheet, + Platform, + ScrollView, + Switch +} from 'react-native' +import { ChevronLeft } from 'lucide-react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' + +const STORAGE_KEY = 'orca:custom-accessory-keys' + +export type CustomKey = { + id: string + label: string + bytes: string + enter: boolean +} + +type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro' + +const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +function ctrlBytes(letter: string): string { + return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) +} + +function altBytes(letter: string): string { + return `\x1b${letter.toLowerCase()}` +} + +type Props = { + visible: boolean + onClose: () => void + onKeysChanged: (keys: CustomKey[]) => void +} + +export async function loadCustomKeys(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as CustomKey[]) : [] + } catch { + return [] + } +} + +async function saveCustomKeys(keys: CustomKey[]): Promise { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys)) +} + +export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { + const [step, setStep] = useState('choose-type') + const [macroLabel, setMacroLabel] = useState('') + const [macroText, setMacroText] = useState('') + const [macroEnter, setMacroEnter] = useState(true) + + useEffect(() => { + if (visible) { + setStep('choose-type') + setMacroLabel('') + setMacroText('') + setMacroEnter(true) + } + }, [visible]) + + const addKey = useCallback( + async (key: Omit) => { + const existing = await loadCustomKeys() + const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` } + const updated = [...existing, newKey] + await saveCustomKeys(updated) + onKeysChanged(updated) + onClose() + }, + [onClose, onKeysChanged] + ) + + const handleCtrlKey = useCallback( + (letter: string) => { + void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleAltKey = useCallback( + (letter: string) => { + void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false }) + }, + [addKey] + ) + + const handleMacroSave = useCallback(() => { + const label = macroLabel.trim() || macroText.trim().slice(0, 12) + const text = macroText + if (!label || !text) return + const bytes = macroEnter ? `${text}\r` : text + void addKey({ label, bytes, enter: false }) + }, [addKey, macroLabel, macroText, macroEnter]) + + const showBack = step !== 'choose-type' + + return ( + + + + + + + {showBack ? ( + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => setStep('choose-type')} + accessibilityLabel="Back" + > + + + ) : ( + + )} + + {step === 'choose-type' && 'Add Shortcut'} + {step === 'pick-ctrl' && 'Ctrl + Key'} + {step === 'pick-alt' && 'Alt + Key'} + {step === 'text-macro' && 'Text Macro'} + + + + + {step === 'choose-type' && ( + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-ctrl')} + > + Ctrl + Key + Control character shortcuts + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('pick-alt')} + > + Alt + Key + Alt/Option key combos + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setStep('text-macro')} + > + Text Macro + Send custom text command + + + )} + + {(step === 'pick-ctrl' || step === 'pick-alt') && ( + + + {ALPHA_KEYS.map((letter) => ( + [styles.keyCell, pressed && styles.keyCellPressed]} + onPress={() => + step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter) + } + > + {letter} + + ))} + + + )} + + {step === 'text-macro' && ( + + + Label + + Command + + + Press Enter + + + + Add Shortcut + + + + )} + + + + ) +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end' + }, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xl + spacing.md, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 + }, + android: { elevation: 8 } + }) + }, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + marginTop: spacing.sm, + marginBottom: spacing.md, + opacity: 0.4 + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.sm + }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + backSpacer: { + width: 30 + }, + title: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center' + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 1 + }, + rowHint: { + fontSize: 12, + color: colors.textMuted + }, + keyGridScroll: { + maxHeight: 240 + }, + keyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + justifyContent: 'center', + padding: spacing.md + }, + keyCell: { + width: 42, + height: 38, + borderRadius: radii.button, + backgroundColor: colors.bgBase, + alignItems: 'center', + justifyContent: 'center' + }, + keyCellPressed: { + backgroundColor: colors.bgRaised + }, + keyCellText: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + fontFamily: typography.monoFamily + }, + macroForm: { + padding: spacing.md, + gap: spacing.sm + }, + fieldLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary + }, + fieldInput: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs + }, + switchLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + saveButton: { + backgroundColor: colors.accentBlue, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button, + alignItems: 'center' + }, + saveButtonDisabled: { + opacity: 0.5 + }, + saveButtonText: { + color: '#fff', + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index a018facc18a..2a6e3f06046 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -11,7 +11,7 @@ import { Image, FlatList } from 'react-native' -import { ChevronDown, Check, Terminal, X } from 'lucide-react-native' +import { ChevronDown, Check, Terminal } from 'lucide-react-native' import Svg, { Path, G } from 'react-native-svg' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' @@ -210,38 +210,44 @@ function PickerListModal({ }) { return ( - - true}> - {title} - item.id} - style={styles.pickerList} - renderItem={({ item }) => { - const selected = item.id === selectedId - return ( - { - onSelect(item) - onClose() - }} - > - {renderIcon?.(item)} - + true}> + + + {title} + + + item.id} + style={styles.pickerList} + ItemSeparatorComponent={() => } + renderItem={({ item }) => { + const selected = item.id === selectedId + return ( + [ + styles.pickerItem, + pressed && styles.pickerItemPressed + ]} + onPress={() => { + onSelect(item) + onClose() + }} > - {item.label} - - {selected && } - - ) - }} - /> - - Cancel - + {renderIcon?.(item)} + + {item.label} + + {selected && } + + ) + }} + /> + @@ -335,15 +341,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) <> - true}> + true}> + - - New Worktree - Pick a repository and agent - - - - + New Worktree + Pick a repository and agent {loading ? ( @@ -458,46 +460,49 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', - justifyContent: 'center', - alignItems: 'center', - padding: spacing.xl + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end' }, - card: { - width: '100%', - maxWidth: 340, - backgroundColor: colors.bgPanel, - borderRadius: 14, - padding: spacing.xl, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xl + spacing.md, ...Platform.select({ ios: { shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12 + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 }, android: { elevation: 8 } }) }, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + marginTop: spacing.sm, + marginBottom: spacing.md, + opacity: 0.4 + }, header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: spacing.lg + paddingHorizontal: spacing.xs, + marginBottom: spacing.md }, title: { - fontSize: 17, + fontSize: 15, fontWeight: '600', color: colors.textPrimary }, subtitle: { - fontSize: 12, - color: colors.textSecondary, + fontSize: 13, + color: colors.textMuted, marginTop: 2 }, - closeButton: { - padding: spacing.xs - }, loadingContainer: { paddingVertical: spacing.xl, alignItems: 'center' @@ -592,33 +597,57 @@ const styles = StyleSheet.create({ }, // Picker sub-modal styles - pickerCard: { - width: '100%', - maxWidth: 320, + pickerBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end' + }, + pickerDrawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xl + spacing.md, maxHeight: '70%', - backgroundColor: colors.bgPanel, - borderRadius: 14, - overflow: 'hidden', ...Platform.select({ ios: { shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12 + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 }, android: { elevation: 8 } }) }, - pickerTitle: { - fontSize: 15, - fontWeight: '600', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 0.5, - paddingHorizontal: spacing.lg, - paddingTop: spacing.lg, + pickerHandle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + marginTop: spacing.sm, + marginBottom: spacing.md, + opacity: 0.4 + }, + pickerHeader: { + paddingHorizontal: spacing.xs, paddingBottom: spacing.sm }, + pickerTitle: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted + }, + pickerGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + pickerSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, pickerList: { flexGrow: 0 }, @@ -626,10 +655,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - paddingVertical: spacing.sm + 2, - paddingHorizontal: spacing.lg + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 }, - pickerItemSelected: { + pickerItemPressed: { backgroundColor: colors.bgRaised }, pickerItemText: { @@ -639,17 +668,5 @@ const styles = StyleSheet.create({ }, pickerItemTextSelected: { fontWeight: '600' - }, - pickerCancel: { - alignItems: 'center', - paddingVertical: spacing.md, - borderTopWidth: 1, - borderTopColor: colors.borderSubtle, - marginTop: spacing.xs - }, - pickerCancelText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' } }) diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx index 2b7397934da..fb2ce69eba7 100644 --- a/mobile/src/components/PickerModal.tsx +++ b/mobile/src/components/PickerModal.tsx @@ -28,37 +28,39 @@ export function PickerModal({ return ( - {}}> - {title} - - {options.map((opt) => { + + + + + {title} + + + + {options.map((opt, i) => { const isSelected = opt.value === selected return ( - { - onSelect(opt.value) - onClose() - }} - > - - - {opt.label} - - {opt.subtitle ? ( - {opt.subtitle} - ) : null} - - {isSelected && } - + + {i > 0 && } + [styles.row, pressed && styles.rowPressed]} + onPress={() => { + onSelect(opt.value) + onClose() + }} + > + + + {opt.label} + + {opt.subtitle ? {opt.subtitle} : null} + + {isSelected && } + + ) })} - - Cancel - - + ) @@ -67,75 +69,76 @@ export function PickerModal({ const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', - justifyContent: 'center', - alignItems: 'center', - padding: spacing.xl + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end' }, - card: { - width: '100%', - maxWidth: 320, - backgroundColor: colors.bgPanel, - borderRadius: 14, - overflow: 'hidden', + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xl + spacing.md, ...Platform.select({ ios: { shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12 + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 }, android: { elevation: 8 } }) }, - title: { - fontSize: 15, - fontWeight: '600', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 0.5, - paddingHorizontal: spacing.lg, - paddingTop: spacing.lg, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + marginTop: spacing.sm, + marginBottom: spacing.md, + opacity: 0.4 + }, + header: { + paddingHorizontal: spacing.xs, paddingBottom: spacing.sm }, - options: { - paddingBottom: spacing.xs + title: { + fontSize: 13, + fontWeight: '500', + color: colors.textMuted }, - option: { + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { flexDirection: 'row', alignItems: 'center', - paddingVertical: spacing.sm + 2, - paddingHorizontal: spacing.lg + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 }, - optionSelected: { + rowPressed: { backgroundColor: colors.bgRaised }, - optionContent: { + rowContent: { flex: 1 }, - optionLabel: { + rowLabel: { fontSize: typography.bodySize, color: colors.textPrimary }, - optionLabelSelected: { - color: colors.textPrimary, + rowLabelSelected: { fontWeight: '600' }, - optionSubtitle: { + rowSubtitle: { fontSize: 11, color: colors.textMuted, marginTop: 1 - }, - closeButton: { - alignItems: 'center', - paddingVertical: spacing.md, - borderTopWidth: 1, - borderTopColor: colors.borderSubtle, - marginTop: spacing.xs - }, - closeText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' } }) diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx index 209578b1f03..ae853138ed9 100644 --- a/mobile/src/components/TextInputModal.tsx +++ b/mobile/src/components/TextInputModal.tsx @@ -36,35 +36,52 @@ export function TextInputModal({ return ( - {}}> - {title} - {message ? {message} : null} - + + + + + {title} + {message ? {message} : null} + + + + + + + + - + [styles.cancelButton, pressed && styles.buttonPressed]} + onPress={onCancel} + > Cancel [ + styles.submitButton, + pressed && styles.buttonPressed, + !value.trim() && styles.submitButtonDisabled + ]} disabled={!value.trim()} onPress={handleSubmit} > Save - + ) @@ -73,73 +90,95 @@ export function TextInputModal({ const styles = StyleSheet.create({ backdrop: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.6)', - justifyContent: 'center', - alignItems: 'center', - padding: spacing.xl + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end' }, - card: { - width: '100%', - maxWidth: 340, - backgroundColor: colors.bgPanel, - borderRadius: 14, - padding: spacing.xl, + drawer: { + backgroundColor: colors.bgBase, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xl + spacing.md, ...Platform.select({ ios: { shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12 + shadowOffset: { width: 0, height: -2 }, + shadowOpacity: 0.2, + shadowRadius: 10 }, android: { elevation: 8 } }) }, + handle: { + alignSelf: 'center', + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.textMuted, + marginTop: spacing.sm, + marginBottom: spacing.md, + opacity: 0.4 + }, + header: { + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, title: { - fontSize: 17, + fontSize: 15, fontWeight: '600', - color: colors.textPrimary, - marginBottom: spacing.xs + color: colors.textPrimary }, message: { fontSize: 13, - color: colors.textSecondary, - marginBottom: spacing.md + color: colors.textMuted, + marginTop: 2 + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + inputWrap: { + padding: spacing.md }, input: { - backgroundColor: colors.bgRaised, + backgroundColor: colors.bgBase, color: colors.textPrimary, borderRadius: radii.input, paddingHorizontal: spacing.md, paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, fontSize: typography.bodySize, borderWidth: 1, - borderColor: colors.borderSubtle, - marginBottom: spacing.lg + borderColor: colors.borderSubtle }, actions: { flexDirection: 'row', justifyContent: 'flex-end', - gap: spacing.sm + gap: spacing.sm, + marginTop: spacing.md }, cancelButton: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, borderRadius: radii.button }, - cancelText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - }, submitButton: { backgroundColor: colors.accentBlue, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, borderRadius: radii.button }, + buttonPressed: { + opacity: 0.7 + }, submitButtonDisabled: { opacity: 0.4 }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, submitText: { color: '#fff', fontSize: typography.bodySize,