From a841b32c3173797bbed64b34076d35bb18a059d7 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Tue, 12 May 2026 21:03:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(rebreak-native):=20=20=E2=80=94?= =?UTF-8?q?=20one=20reusable=20bottom-sheet=20composable=20(phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom modals each rolled their own Modal + animated-height + PanResponder + keyboard handling, inconsistently. 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 too instead of pinning magic numbers) Phase 2 (next): — 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) --- apps/rebreak-native/components/FormSheet.tsx | 246 ++++++++++++++++++ .../components/PostCommentsSheet.tsx | 4 +- .../blocker/DeactivationExplainerSheet.tsx | 54 +--- .../blocker/ProtectionDetailsSheet.tsx | 153 +---------- 4 files changed, 276 insertions(+), 181 deletions(-) create mode 100644 apps/rebreak-native/components/FormSheet.tsx diff --git a/apps/rebreak-native/components/FormSheet.tsx b/apps/rebreak-native/components/FormSheet.tsx new file mode 100644 index 0000000..baaf51b --- /dev/null +++ b/apps/rebreak-native/components/FormSheet.tsx @@ -0,0 +1,246 @@ +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: + * - `` 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 }} />} + + + + ); +} diff --git a/apps/rebreak-native/components/PostCommentsSheet.tsx b/apps/rebreak-native/components/PostCommentsSheet.tsx index 5faf3bd..1030009 100644 --- a/apps/rebreak-native/components/PostCommentsSheet.tsx +++ b/apps/rebreak-native/components/PostCommentsSheet.tsx @@ -47,8 +47,10 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) { // Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt // `Dimensions.get` (statisch beim Modul-Load). const { height: SCREEN_HEIGHT } = useWindowDimensions(); + // App-Konvention: Sheets nie höher als 75 % vom Screen (auch beim Hochziehen / mit Tastatur). + // TODO(phase-1b): dieses Sheet auf umstellen, statt die Magic-Numbers hier zu pflegen. const COLLAPSED_HEIGHT = SCREEN_HEIGHT * 0.65; - const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.92; + const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.75; const MIN_HEIGHT = SCREEN_HEIGHT * 0.35; // Sheet-Höhe animiert (height-based, bottom: 0 fix → Input bleibt immer am Edge sichtbar). diff --git a/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx b/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx index 371a5c6..1346952 100644 --- a/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx +++ b/apps/rebreak-native/components/blocker/DeactivationExplainerSheet.tsx @@ -1,9 +1,10 @@ -import { Modal, View, Text, TouchableOpacity, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native'; +import { View, Text, TouchableOpacity, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useColors } from '../../lib/theme'; +import { FormSheet } from '../FormSheet'; type Props = { visible: boolean; @@ -72,44 +73,18 @@ export function DeactivationExplainerSheet({ } return ( - - - {/* Header — paddingTop berücksichtigt Notch/Statusbar (pageSheet auf iOS gibt - insets korrekt weiter; auf Android sichert es den Statusbar-Bereich ab). */} - - - - {t('common.back')} - - - - {t('blocker.deactivation_heading')} - - - - - + {t('blocker.deactivation_title')} @@ -189,9 +164,8 @@ export function DeactivationExplainerSheet({ {submitting ? t('blocker.deactivation_starting') : t('blocker.deactivation_start_anyway')} - - - + + ); } diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx index 596500d..3410dd4 100644 --- a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx +++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx @@ -1,13 +1,10 @@ import { useEffect, useRef, useState } from 'react'; import { - Modal, View, Text, TouchableOpacity, ScrollView, - Dimensions, Animated, - PanResponder, ActivityIndicator, Easing, } from 'react-native'; @@ -18,6 +15,7 @@ import Svg, { Path, Circle } from 'react-native-svg'; import type { ProtectionState } from '../../lib/protection'; import { apiFetch } from '../../lib/api'; import { useColors } from '../../lib/theme'; +import { FormSheet } from '../FormSheet'; type Props = { visible: boolean; @@ -38,12 +36,6 @@ type StatsResponse = { avgApprovalWaitDays: number; }; -const SCREEN_HEIGHT = Dimensions.get('window').height; -const DEFAULT_HEIGHT = SCREEN_HEIGHT * 0.9; -const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.95; -const MIN_HEIGHT = SCREEN_HEIGHT * 0.4; -const DISMISS_HEIGHT = SCREEN_HEIGHT * 0.3; - // Brand colors const HERO_COLOR = '#f97316'; // orange-500 (counter accent) const SEG_ACTIVE = '#16a34a'; @@ -61,63 +53,6 @@ export function ProtectionDetailsSheet({ const insets = useSafeAreaInsets(); const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US'; - const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current; - const dismissY = useRef(new Animated.Value(0)).current; - const currentHeight = useRef(DEFAULT_HEIGHT); - - useEffect(() => { - if (visible) { - sheetHeight.setValue(DEFAULT_HEIGHT); - dismissY.setValue(0); - currentHeight.current = DEFAULT_HEIGHT; - } - }, [visible, sheetHeight, dismissY]); - - const handleClose = () => { - sheetHeight.setValue(DEFAULT_HEIGHT); - dismissY.setValue(0); - currentHeight.current = DEFAULT_HEIGHT; - onClose(); - }; - - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, - onPanResponderTerminationRequest: () => false, - onPanResponderMove: (_, g) => { - const next = currentHeight.current - g.dy; - const clamped = Math.max(DISMISS_HEIGHT - 60, Math.min(EXPANDED_HEIGHT + 20, next)); - sheetHeight.setValue(clamped); - }, - onPanResponderRelease: (_, g) => { - const finalH = currentHeight.current - g.dy; - const velocity = g.vy; - - if (finalH < DISMISS_HEIGHT || velocity > 1.5) { - Animated.timing(dismissY, { - toValue: SCREEN_HEIGHT, - duration: 200, - useNativeDriver: true, - }).start(() => handleClose()); - return; - } - - let target = finalH; - if (velocity < -1.5) target = EXPANDED_HEIGHT; - const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target)); - - Animated.spring(sheetHeight, { - toValue: clamped, - useNativeDriver: false, - friction: 9, - tension: 70, - }).start(); - currentHeight.current = clamped; - }, - }), - ).current; - const [stats, setStats] = useState(null); const [loadingStats, setLoadingStats] = useState(false); @@ -142,79 +77,19 @@ export function ProtectionDetailsSheet({ const avgWait = stats?.avgApprovalWaitDays ?? 0; return ( - - - - - - {/* Drag-Bar */} - - - - - {/* Header */} - - - - {t('blocker.details_title')} - - - - {t('blocker.details_done')} - - - - - {loadingStats && !stats ? ( @@ -370,10 +245,8 @@ export function ProtectionDetailsSheet({ - - - - + + ); }