| 1 | import type { LucideIcon } from "lucide-react-native"; |
| 2 | import { type ReactNode, useEffect, useState } from "react"; |
| 3 | import { KeyboardAvoidingView, Modal, Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; |
| 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | |
| 6 | import { Button, Text } from "@/components/ui"; |
| 7 | import { radius, space, useColors } from "@/theme"; |
| 8 | |
| 9 | /** A panel from the bottom over a dimmed screen, closed by tapping outside it. */ |
| 10 | export function Sheet({ visible, onClose, children }: { visible: boolean; onClose: () => void; children: ReactNode }) { |
| 11 | const colors = useColors(); |
| 12 | const insets = useSafeAreaInsets(); |
| 13 | return ( |
| 14 | <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent> |
| 15 | <Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel="Close" /> |
| 16 | <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined}> |
| 17 | <View style={[styles.sheet, { backgroundColor: colors.surface, borderColor: colors.line, paddingBottom: insets.bottom + space.md }]}> |
| 18 | <View style={[styles.grabber, { backgroundColor: colors.lineStrong }]} /> |
| 19 | {children} |
| 20 | </View> |
| 21 | </KeyboardAvoidingView> |
| 22 | </Modal> |
| 23 | ); |
| 24 | } |
| 25 | |
| 26 | /** A row in a sheet: an icon and what it does. */ |
| 27 | export function SheetAction({ icon: Icon, label, danger, onPress }: { icon: LucideIcon; label: string; danger?: boolean; onPress: () => void }) { |
| 28 | const colors = useColors(); |
| 29 | return ( |
| 30 | <Pressable onPress={onPress} style={({ pressed }) => [styles.action, { backgroundColor: pressed ? colors.raised : "transparent" }]}> |
| 31 | <Icon size={20} color={danger ? colors.danger : colors.fg} /> |
| 32 | <Text style={{ color: danger ? colors.danger : colors.fg }}>{label}</Text> |
| 33 | </Pressable> |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | /** Asks for one value in a sheet: a card's amount, a new message text. */ |
| 38 | export function PromptSheet({ |
| 39 | visible, |
| 40 | title, |
| 41 | label, |
| 42 | placeholder, |
| 43 | initial = "", |
| 44 | numeric, |
| 45 | multiline, |
| 46 | confirm, |
| 47 | onCancel, |
| 48 | onDone, |
| 49 | }: { |
| 50 | visible: boolean; |
| 51 | title: string; |
| 52 | label?: string; |
| 53 | placeholder?: string; |
| 54 | initial?: string; |
| 55 | numeric?: boolean; |
| 56 | multiline?: boolean; |
| 57 | confirm: string; |
| 58 | onCancel: () => void; |
| 59 | onDone: (value: string) => void; |
| 60 | }) { |
| 61 | const colors = useColors(); |
| 62 | const [value, setValue] = useState(initial); |
| 63 | useEffect(() => { |
| 64 | if (visible) setValue(initial); |
| 65 | }, [visible, initial]); |
| 66 | return ( |
| 67 | <Sheet visible={visible} onClose={onCancel}> |
| 68 | <View style={{ paddingHorizontal: space.lg, gap: space.md }}> |
| 69 | <Text size="heading">{title}</Text> |
| 70 | {label ? ( |
| 71 | <Text size="small" tone="muted"> |
| 72 | {label} |
| 73 | </Text> |
| 74 | ) : null} |
| 75 | <TextInput |
| 76 | autoFocus |
| 77 | value={value} |
| 78 | onChangeText={setValue} |
| 79 | placeholder={placeholder} |
| 80 | placeholderTextColor={colors.faint} |
| 81 | keyboardType={numeric ? "decimal-pad" : "default"} |
| 82 | multiline={multiline} |
| 83 | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong, minHeight: multiline ? 96 : 46 }]} |
| 84 | /> |
| 85 | <View style={{ flexDirection: "row", gap: space.sm }}> |
| 86 | <View style={{ flex: 1 }}> |
| 87 | <Button title="Cancel" variant="secondary" onPress={onCancel} /> |
| 88 | </View> |
| 89 | <View style={{ flex: 1 }}> |
| 90 | <Button title={confirm} disabled={!value.trim()} onPress={() => onDone(value.trim())} /> |
| 91 | </View> |
| 92 | </View> |
| 93 | </View> |
| 94 | </Sheet> |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | const styles = StyleSheet.create({ |
| 99 | backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)" }, |
| 100 | sheet: { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, paddingTop: space.sm, gap: space.sm }, |
| 101 | grabber: { alignSelf: "center", width: 36, height: 4, borderRadius: 2, marginBottom: space.xs }, |
| 102 | action: { flexDirection: "row", alignItems: "center", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md }, |
| 103 | input: { borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15, textAlignVertical: "top" }, |
| 104 | }); |