import { ReactNode, useEffect, useRef, useState } from 'react'; import { Animated, Keyboard, Modal, PanResponder, Platform, Text, TouchableOpacity, View, useWindowDimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useColors } from '../lib/theme'; /** * App-weites Bottom-Sheet — DAS eine Pattern für alle Custom-Modals. * * Verallgemeinert das verifizierte `PostCommentsSheet`-Pattern: * - `` mit hellem (oder ganz ohne) Backdrop — verdunkelt den * Main-Screen nie stark. * - **Standard-Header**: Grabber-Bar mittig + Titel **links**. KEINE * „Fertig"/„Abbrechen"/„Zurück"-Buttons — Schließen = runterswipen / Backdrop-Tap. * - **Resizable**: Drag am Handle/Header zieht das Sheet größer/kleiner; * Drag nach unten unter `minHeightPct` (oder schneller Flick) → dismiss. * - **Höhe ≤ 75 % Screen**, IMMER (Drag + Keyboard-Expand sind hart gedeckelt). * - **Keyboard-aware**: Tastatur auf → Sheet wächst um Tastatur-Höhe (gedeckelt), * `paddingBottom: keyboardHeight` (iOS) schiebt den Inhalt exakt über die * Tastatur. Android: `windowSoftInputMode=adjustResize` im Manifest macht das. * * Driver-Trennung (sonst „Style property 'height' is not supported by native * animated module"-Crash): äußere View animiert `height` im JS-Driver, innere * View animiert `transform: translateY` (Slide/Dismiss) im Native-Driver. * * Der Inhalt (`children`) wird in einem `flex:1`-Wrapper unter dem Header * gerendert — der Caller layoutet selbst (z.B. `flex:1`-ScrollView + Bottom-Bar * für eine Input-Zeile, die dann automatisch über der Tastatur sitzt). * * Für progressive Mehr-Feld-Formulare (Mail-Account, Domain hinzufügen) kommt * `` als Inhalt rein (Phase 2). */ const MAX_HEIGHT_PCT = 0.75; // harter Cap — nie höher const DRAG_FLICK_VELOCITY = 1.5; export interface FormSheetProps { visible: boolean; onClose: () => void; /** Titel links im Header. */ title: string; children: ReactNode; /** Start-Höhe als Anteil der Screen-Höhe (0..0.75). Default 0.5. */ initialHeightPct?: number; /** Drag-down unter diesen Anteil (oder Flick) → dismiss. Default 0.3. */ minHeightPct?: number; /** Backdrop-Deckkraft (0 = kein Dim). Default 0.12 — Main-Screen bleibt sichtbar. */ backdropOpacity?: number; /** Default true — Tap auf Backdrop schließt das Sheet. */ dismissOnBackdrop?: boolean; /** Default true — fügt unten einen Safe-Area-Spacer ein wenn die Tastatur zu ist. */ safeAreaBottom?: boolean; /** Default true — Sheet wächst/expandiert wenn die Tastatur aufgeht. Für * Sheets ohne Input egal; auf false setzen wenn man's bewusst nicht will. */ growWithKeyboard?: boolean; /** Border-Radius oben. Default 24. */ topRadius?: number; } export function FormSheet({ visible, onClose, title, children, initialHeightPct = 0.5, minHeightPct = 0.3, backdropOpacity = 0.12, dismissOnBackdrop = true, safeAreaBottom = true, growWithKeyboard = true, topRadius = 24, }: FormSheetProps) { const colors = useColors(); const insets = useSafeAreaInsets(); // useWindowDimensions: live — auf Android schrumpft height bei offener Tastatur // (adjustResize), daher dynamisch statt Dimensions.get (statisch beim Modul-Load). const { height: SCREEN_H } = useWindowDimensions(); const maxHeight = SCREEN_H * MAX_HEIGHT_PCT; const initialHeight = Math.min(SCREEN_H * initialHeightPct, maxHeight); const dismissHeight = SCREEN_H * minHeightPct; const sheetHeight = useRef(new Animated.Value(initialHeight)).current; // JS driver const dismissY = useRef(new Animated.Value(0)).current; // native driver const currentHeight = useRef(initialHeight); // letzte „Ruhe"-Höhe (Drag oder initial) const keyboardHeightRef = useRef(0); const [keyboardHeight, setKeyboardHeight] = useState(0); // Reset bei (Wieder-)Öffnen useEffect(() => { if (visible) { sheetHeight.setValue(initialHeight); dismissY.setValue(0); currentHeight.current = initialHeight; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); const handleClose = () => { Keyboard.dismiss(); sheetHeight.setValue(initialHeight); dismissY.setValue(0); currentHeight.current = initialHeight; onClose(); }; // Keyboard: Sheet wächst (gedeckelt) + paddingBottom schiebt Inhalt über die Tastatur useEffect(() => { if (!growWithKeyboard) return; const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const showSub = Keyboard.addListener(showEvent, (e) => { const h = e.endCoordinates.height; keyboardHeightRef.current = h; setKeyboardHeight(h); Animated.timing(sheetHeight, { toValue: Math.min(currentHeight.current + h, maxHeight), duration: Platform.OS === 'ios' ? e.duration ?? 250 : 200, useNativeDriver: false, }).start(); }); const hideSub = Keyboard.addListener(hideEvent, (e) => { keyboardHeightRef.current = 0; setKeyboardHeight(0); Animated.timing(sheetHeight, { toValue: Math.min(currentHeight.current, maxHeight), duration: Platform.OS === 'ios' ? e?.duration ?? 250 : 200, useNativeDriver: false, }).start(); }); return () => { showSub.remove(); hideSub.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [growWithKeyboard, maxHeight]); const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, onPanResponderMove: (_, g) => { // Drag rauf (dy<0) → höher. Mit offener Tastatur rechnen wir vom // gewachsenen Stand aus. const base = currentHeight.current + keyboardHeightRef.current; const next = base - g.dy; sheetHeight.setValue(Math.max(dismissHeight - 60, Math.min(maxHeight + 16, next))); }, onPanResponderRelease: (_, g) => { const base = currentHeight.current + keyboardHeightRef.current; const finalH = base - g.dy; const v = g.vy; if (finalH < dismissHeight || v > DRAG_FLICK_VELOCITY) { Animated.timing(dismissY, { toValue: SCREEN_H, duration: 200, useNativeDriver: true, }).start(() => handleClose()); return; } let target = finalH; if (v < -DRAG_FLICK_VELOCITY) target = maxHeight; const clamped = Math.max(SCREEN_H * minHeightPct, Math.min(maxHeight, target)); Animated.spring(sheetHeight, { toValue: clamped, useNativeDriver: false, friction: 9, tension: 70, }).start(); // „Ruhe"-Höhe = ohne Tastatur-Anteil merken currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current); }, }), ).current; const dragHandlers = panResponder.panHandlers; return ( {/* Backdrop */} {/* Outer: animated height (JS driver) */} {/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */} {/* Grabber-Bar (mittig, drag-area) */} {/* Header: Titel links — keine Buttons. Auch drag-area. */} {title} {/* Inhalt */} {children} {/* Safe-Area-Spacer (nur wenn Tastatur zu) */} {safeAreaBottom && 0 ? 0 : insets.bottom }} />} ); }