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>
This commit is contained in:
parent
52fd1bcce3
commit
a841b32c31
246
apps/rebreak-native/components/FormSheet.tsx
Normal file
246
apps/rebreak-native/components/FormSheet.tsx
Normal file
@ -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:
|
||||
* - `<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>
|
||||
);
|
||||
}
|
||||
@ -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 <FormSheet> 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).
|
||||
|
||||
@ -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 (
|
||||
<Modal
|
||||
<FormSheet
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
onClose={onClose}
|
||||
title={t('blocker.deactivation_heading')}
|
||||
initialHeightPct={0.62}
|
||||
minHeightPct={0.3}
|
||||
safeAreaBottom={false}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||
{/* Header — paddingTop berücksichtigt Notch/Statusbar (pageSheet auf iOS gibt
|
||||
insets korrekt weiter; auf Android sichert es den Statusbar-Bereich ab). */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: insets.top + 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
activeOpacity={0.6}
|
||||
style={{ minWidth: 50 }}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{t('common.back')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||
{t('blocker.deactivation_heading')}
|
||||
</Text>
|
||||
<View style={{ width: 50 }} />
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 12) + 24, gap: 18 }}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 12) + 24, gap: 18 }}
|
||||
>
|
||||
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text }}>
|
||||
{t('blocker.deactivation_title')}
|
||||
</Text>
|
||||
@ -189,9 +164,8 @@ export function DeactivationExplainerSheet({
|
||||
{submitting ? t('blocker.deactivation_starting') : t('blocker.deactivation_start_anyway')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
</FormSheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<StatsResponse | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
|
||||
@ -142,79 +77,19 @@ export function ProtectionDetailsSheet({
|
||||
const avgWait = stats?.avgApprovalWaitDays ?? 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<FormSheet
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={handleClose}
|
||||
statusBarTranslucent
|
||||
onClose={onClose}
|
||||
title={t('blocker.details_title')}
|
||||
initialHeightPct={0.75}
|
||||
minHeightPct={0.3}
|
||||
safeAreaBottom={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
activeOpacity={1}
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: sheetHeight,
|
||||
}}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 32, gap: 18 }}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: 'hidden',
|
||||
transform: [{ translateY: dismissY }],
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
}}
|
||||
>
|
||||
{/* Drag-Bar */}
|
||||
<View
|
||||
{...panResponder.panHandlers}
|
||||
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
|
||||
>
|
||||
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
|
||||
</View>
|
||||
|
||||
{/* Header */}
|
||||
<View
|
||||
{...panResponder.panHandlers}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 50 }} />
|
||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||
{t('blocker.details_title')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleClose} hitSlop={10} activeOpacity={0.6} style={{ width: 50, alignItems: 'flex-end' }}>
|
||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||||
{t('blocker.details_done')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: Math.max(insets.bottom, 16) + 32, gap: 18 }}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{loadingStats && !stats ? (
|
||||
<View style={{ padding: 40, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#737373" />
|
||||
@ -370,10 +245,8 @@ export function ProtectionDetailsSheet({
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
</FormSheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user