chahinebrini a841b32c31 feat(rebreak-native): <FormSheet> — one reusable bottom-sheet composable (phase 1)
The custom modals each rolled their own Modal + animated-height + PanResponder +
keyboard handling, inconsistently. <FormSheet> is the single parametrized
composable, generalized from the proven PostCommentsSheet pattern:

  - standard header: centred grabber + left-aligned title — NO Fertig/Abbrechen/
    Zurück buttons (dismiss = swipe down / backdrop tap)
  - resizable via drag on handle/header; drag-down past minHeightPct (or a fast
    flick) dismisses
  - height hard-capped at 75% of the screen — drag AND keyboard-expand
  - keyboard-aware: sheet grows by the keyboard height (capped), iOS paddingBottom
    pushes the content exactly above the keyboard; Android adjustResize handles it
  - JS-driver height / native-driver translateY split (avoids the "height not
    supported by native animated module" crash)
  - props: title, initialHeightPct, minHeightPct, backdropOpacity, dismissOnBackdrop,
    safeAreaBottom, growWithKeyboard, topRadius

Migrated (phase 1 — the no-input content sheets):
  - ProtectionDetailsSheet → drops the bespoke Modal/PanResponder + the "Fertig"
    header button; was 0.9–0.95 tall, now ≤0.75
  - DeactivationExplainerSheet → was a pageSheet Modal with a "Zurück" button;
    now the standard bottom sheet, header button gone
  - PostCommentsSheet → capped its expand height 0.92 → 0.75 (TODO phase-1b: move
    it onto <FormSheet> too instead of pinning magic numbers)

Phase 2 (next): <SheetFieldStack> — progressive multi-input flow (active input
pinned above the keyboard + "→" to advance, filled fields stack above, the rest
of the form reveals after the last field) for ConnectMailSheet / AddDomainSheet /
EditMailAccountSheet / CreateRoomSheet; then the auth/edit full-screen pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:03:17 +02:00

247 lines
9.2 KiB
TypeScript

import { ReactNode, useEffect, useRef, useState } from 'react';
import {
Animated,
Keyboard,
Modal,
PanResponder,
Platform,
Pressable,
Text,
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:
* - `<Modal transparent>` 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
* `<SheetFieldStack>` 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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={handleClose} statusBarTranslucent>
{/* Backdrop */}
<Pressable
onPress={dismissOnBackdrop ? handleClose : undefined}
style={{ flex: 1, backgroundColor: `rgba(0,0,0,${backdropOpacity})` }}
/>
{/* Outer: animated height (JS driver) */}
<Animated.View
style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: sheetHeight }}
>
{/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */}
<Animated.View
style={{
flex: 1,
backgroundColor: colors.bg,
borderTopLeftRadius: topRadius,
borderTopRightRadius: topRadius,
overflow: 'hidden',
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Grabber-Bar (mittig, drag-area) */}
<View {...dragHandlers} style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}>
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
</View>
{/* Header: Titel links — keine Buttons. Auch drag-area. */}
<View
{...dragHandlers}
style={{
paddingHorizontal: 20,
paddingTop: 4,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{title}
</Text>
</View>
{/* Inhalt */}
<View style={{ flex: 1 }}>{children}</View>
{/* Safe-Area-Spacer (nur wenn Tastatur zu) */}
{safeAreaBottom && <View style={{ height: keyboardHeight > 0 ? 0 : insets.bottom }} />}
</Animated.View>
</Animated.View>
</Modal>
);
}