mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Improve mobile terminal UX and unify modal design language
- Add custom keyboard shortcuts (Ctrl+Key, Alt+Key, text macros) with persistent storage - Add built-in terminal shortcuts (Ctrl+L/Z/R/A/E/W/U) and resize toggle - Allow sending Enter on empty input for terminal convenience - Fix delete worktree: correct RPC method (worktree.rm) with optimistic UI - Fix modal transition flicker between action sheet and confirm dialog - Unify all modals to consistent bottom drawer pattern with inner action groups Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 */}
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null}
|
||||
visible={actionTarget != null && confirmDelete == null}
|
||||
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
|
||||
message={actionTarget?.branch}
|
||||
actions={
|
||||
@@ -737,12 +749,16 @@ export default function HostScreen() {
|
||||
onPress: () => {
|
||||
void handleDeleteWorktree(confirmDelete)
|
||||
setConfirmDelete(null)
|
||||
setActionTarget(null)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
onClose={() => {
|
||||
setConfirmDelete(null)
|
||||
setActionTarget(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Host remove confirmation */}
|
||||
|
||||
@@ -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<Terminal | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
|
||||
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
|
||||
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
|
||||
const [deleteKeyTarget, setDeleteKeyTarget] = useState<CustomKey | null>(null)
|
||||
const [fittedHandles, setFittedHandles] = useState<Set<string>>(
|
||||
() => 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}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
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) ? (
|
||||
<Monitor size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
|
||||
) : (
|
||||
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
{ACCESSORY_KEYS.map((key) => (
|
||||
<Pressable
|
||||
key={key.label}
|
||||
@@ -822,6 +877,36 @@ export default function SessionScreen() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{customKeys.map((key) => (
|
||||
<Pressable
|
||||
key={key.id}
|
||||
style={({ pressed }) => [
|
||||
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}`}
|
||||
>
|
||||
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
|
||||
{key.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.accessoryKey, pressed && styles.accessoryKeyPressed]}
|
||||
onPress={() => setShowCustomKeyModal(true)}
|
||||
accessibilityLabel="Add custom shortcut"
|
||||
>
|
||||
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -906,6 +991,29 @@ export default function SessionScreen() {
|
||||
onSubmit={(value) => void handleRenameTerminal(value)}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
<CustomKeyModal
|
||||
visible={showCustomKeyModal}
|
||||
onClose={() => setShowCustomKeyModal(false)}
|
||||
onKeysChanged={setCustomKeys}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={deleteKeyTarget != null}
|
||||
title={deleteKeyTarget?.label ?? 'Shortcut'}
|
||||
message="Remove this custom shortcut?"
|
||||
actions={[
|
||||
{
|
||||
label: 'Remove',
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
if (deleteKeyTarget) {
|
||||
void handleDeleteCustomKey(deleteKeyTarget)
|
||||
}
|
||||
setDeleteKeyTarget(null)
|
||||
}
|
||||
}
|
||||
]}
|
||||
onClose={() => setDeleteKeyTarget(null)}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
@@ -1073,6 +1181,10 @@ const styles = StyleSheet.create({
|
||||
accessoryKeyPressed: {
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
customAccessoryKey: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
accessoryKeyDisabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable style={styles.sheet} onPress={() => {}}>
|
||||
<View style={styles.drawer}>
|
||||
<View style={styles.handle} />
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerText}>
|
||||
{title ? <Text style={styles.title}>{title}</Text> : null}
|
||||
|
||||
{(title || message) && (
|
||||
<View style={styles.header}>
|
||||
{title ? (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.closeButton, pressed && styles.rowPressed]}
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Close"
|
||||
>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
<View style={styles.actionGroup}>
|
||||
{actions.map((action, i) => {
|
||||
const Icon = iconForAction(action.label, action.destructive, action.icon)
|
||||
return (
|
||||
<Pressable
|
||||
key={action.label}
|
||||
style={({ pressed }) => [styles.action, pressed && styles.rowPressed]}
|
||||
onPress={() => {
|
||||
onClose()
|
||||
action.onPress()
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[styles.actionIcon, action.destructive && styles.actionIconDestructive]}
|
||||
<View key={action.label}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||
onPress={() => {
|
||||
onClose()
|
||||
action.onPress()
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
size={16}
|
||||
color={action.destructive ? colors.statusRed : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.actionText, action.destructive && styles.actionTextDestructive]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
action.destructive && styles.actionTextDestructive
|
||||
]}
|
||||
>
|
||||
{action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<CustomKey[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as CustomKey[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
|
||||
}
|
||||
|
||||
export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
const [step, setStep] = useState<Step>('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<CustomKey, 'id'>) => {
|
||||
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 (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<View style={styles.drawer}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
<View style={styles.header}>
|
||||
{showBack ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => setStep('choose-type')}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.backSpacer} />
|
||||
)}
|
||||
<Text style={styles.title}>
|
||||
{step === 'choose-type' && 'Add Shortcut'}
|
||||
{step === 'pick-ctrl' && 'Ctrl + Key'}
|
||||
{step === 'pick-alt' && 'Alt + Key'}
|
||||
{step === 'text-macro' && 'Text Macro'}
|
||||
</Text>
|
||||
<View style={styles.backSpacer} />
|
||||
</View>
|
||||
|
||||
{step === 'choose-type' && (
|
||||
<View style={styles.group}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('pick-ctrl')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Ctrl + Key</Text>
|
||||
<Text style={styles.rowHint}>Control character shortcuts</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('pick-alt')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Alt + Key</Text>
|
||||
<Text style={styles.rowHint}>Alt/Option key combos</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setStep('text-macro')}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Text Macro</Text>
|
||||
<Text style={styles.rowHint}>Send custom text command</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(step === 'pick-ctrl' || step === 'pick-alt') && (
|
||||
<View style={styles.group}>
|
||||
<ScrollView style={styles.keyGridScroll} contentContainerStyle={styles.keyGrid}>
|
||||
{ALPHA_KEYS.map((letter) => (
|
||||
<Pressable
|
||||
key={letter}
|
||||
style={({ pressed }) => [styles.keyCell, pressed && styles.keyCellPressed]}
|
||||
onPress={() =>
|
||||
step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter)
|
||||
}
|
||||
>
|
||||
<Text style={styles.keyCellText}>{letter}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{step === 'text-macro' && (
|
||||
<View style={styles.group}>
|
||||
<View style={styles.macroForm}>
|
||||
<Text style={styles.fieldLabel}>Label</Text>
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={macroLabel}
|
||||
onChangeText={setMacroLabel}
|
||||
placeholder="e.g. Build"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Command</Text>
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={macroText}
|
||||
onChangeText={setMacroText}
|
||||
placeholder="e.g. pnpm build"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Press Enter</Text>
|
||||
<Switch
|
||||
value={macroEnter}
|
||||
onValueChange={setMacroEnter}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.saveButton, !macroText.trim() && styles.saveButtonDisabled]}
|
||||
disabled={!macroText.trim()}
|
||||
onPress={handleMacroSave}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>Add Shortcut</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
@@ -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<T extends { id: string; label: string }>({
|
||||
}) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<View style={styles.pickerCard} onStartShouldSetResponder={() => true}>
|
||||
<Text style={styles.pickerTitle}>{title}</Text>
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={styles.pickerList}
|
||||
renderItem={({ item }) => {
|
||||
const selected = item.id === selectedId
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.pickerItem, selected && styles.pickerItemSelected]}
|
||||
onPress={() => {
|
||||
onSelect(item)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{renderIcon?.(item)}
|
||||
<Text
|
||||
style={[styles.pickerItemText, selected && styles.pickerItemTextSelected]}
|
||||
numberOfLines={1}
|
||||
<Pressable style={styles.pickerBackdrop} onPress={onClose}>
|
||||
<View style={styles.pickerDrawer} onStartShouldSetResponder={() => true}>
|
||||
<View style={styles.pickerHandle} />
|
||||
<View style={styles.pickerHeader}>
|
||||
<Text style={styles.pickerTitle}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.pickerGroup}>
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={styles.pickerList}
|
||||
ItemSeparatorComponent={() => <View style={styles.pickerSeparator} />}
|
||||
renderItem={({ item }) => {
|
||||
const selected = item.id === selectedId
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.pickerItem,
|
||||
pressed && styles.pickerItemPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(item)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{selected && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Pressable style={styles.pickerCancel} onPress={onClose}>
|
||||
<Text style={styles.pickerCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
{renderIcon?.(item)}
|
||||
<Text
|
||||
style={[styles.pickerItemText, selected && styles.pickerItemTextSelected]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{selected && <Check size={14} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
@@ -335,15 +341,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
|
||||
<>
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<View style={styles.card} onStartShouldSetResponder={() => true}>
|
||||
<View style={styles.drawer} onStartShouldSetResponder={() => true}>
|
||||
<View style={styles.handle} />
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>New Worktree</Text>
|
||||
<Text style={styles.subtitle}>Pick a repository and agent</Text>
|
||||
</View>
|
||||
<Pressable style={styles.closeButton} onPress={onClose} hitSlop={8}>
|
||||
<X size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>New Worktree</Text>
|
||||
<Text style={styles.subtitle}>Pick a repository and agent</Text>
|
||||
</View>
|
||||
|
||||
{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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,37 +28,39 @@ export function PickerModal<T extends string = string>({
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={styles.options}>
|
||||
{options.map((opt) => {
|
||||
<View style={styles.drawer}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.group}>
|
||||
{options.map((opt, i) => {
|
||||
const isSelected = opt.value === selected
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
style={[styles.option, isSelected && styles.optionSelected]}
|
||||
onPress={() => {
|
||||
onSelect(opt.value)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<View style={styles.optionContent}>
|
||||
<Text style={[styles.optionLabel, isSelected && styles.optionLabelSelected]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{opt.subtitle ? (
|
||||
<Text style={styles.optionSubtitle}>{opt.subtitle}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{isSelected && <Check size={16} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
<View key={opt.value}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => {
|
||||
onSelect(opt.value)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{opt.subtitle ? <Text style={styles.rowSubtitle}>{opt.subtitle}</Text> : null}
|
||||
</View>
|
||||
{isSelected && <Check size={16} color={colors.textPrimary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<Pressable style={styles.closeButton} onPress={onClose}>
|
||||
<Text style={styles.closeText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)
|
||||
@@ -67,75 +69,76 @@ export function PickerModal<T extends string = string>({
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -36,35 +36,52 @@ export function TextInputModal({
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<Pressable style={styles.backdrop} onPress={onCancel}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
<View style={styles.drawer}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.group}>
|
||||
<View style={styles.inputWrap}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmit}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.cancelButton} onPress={onCancel}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.cancelButton, pressed && styles.buttonPressed]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.submitButton, !value.trim() && styles.submitButtonDisabled]}
|
||||
style={({ pressed }) => [
|
||||
styles.submitButton,
|
||||
pressed && styles.buttonPressed,
|
||||
!value.trim() && styles.submitButtonDisabled
|
||||
]}
|
||||
disabled={!value.trim()}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={styles.submitText}>Save</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user