Alle <Pressable style={({pressed}) => ({...})}> ersetzt — style-Funktion
droppt auf Android (New Arch) intermittierend width/height, führt zu 0×0
unsichtbaren Elementen. TouchableOpacity mit activeOpacity ist stabil.
Außerdem übrige Pressables (plain style) aus components/ und app/
migriert sowie zwei überschüssige </View>-Tags in chat.tsx + RoomCard.tsx
entfernt die TS-Fehler verursacht haben.
64 Dateien, typecheck sauber.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
6.6 KiB
TypeScript
241 lines
6.6 KiB
TypeScript
/**
|
|
* OptionsBottomSheet — Pixel-perfect iOS UIAlertController.actionSheet replication.
|
|
*
|
|
* Pattern: 2 separate Cards (Options + Cancel) mit Gap dazwischen, horizontal margin,
|
|
* native iOS-Typografie + Spacing. Auf iOS 26 funktioniert das native ActionSheetIOS
|
|
* nicht mehr klassisch (centered popover) → eigene Implementation für konsistenten
|
|
* bottom-up sheet-look auf jeder iOS-Version.
|
|
*
|
|
* Use für:
|
|
* - Geschlecht (3), kurze Auswahl-Listen, Confirm-Dialogs mit destructive-Action
|
|
* Use NICHT für:
|
|
* - Lange Listen (>7) → WheelPickerModal
|
|
* - Free-text input → eigene Sheet (z.B. AddDomainSheet)
|
|
*/
|
|
import { useEffect, useRef } from 'react';
|
|
import {
|
|
Modal,
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
TouchableOpacity,
|
|
Animated,
|
|
Easing,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useColors } from '../lib/theme';
|
|
|
|
type Option<T> = {
|
|
value: T;
|
|
label: string;
|
|
/** Rendert in roter Farbe (für „Löschen", „Reset" etc) */
|
|
destructive?: boolean;
|
|
};
|
|
|
|
type Props<T extends string | number> = {
|
|
visible: boolean;
|
|
title?: string;
|
|
message?: string;
|
|
options: Option<T>[];
|
|
value?: T | null;
|
|
onSelect: (value: T) => void;
|
|
onClose: () => void;
|
|
};
|
|
|
|
export function OptionsBottomSheet<T extends string | number>({
|
|
visible,
|
|
title,
|
|
message,
|
|
options,
|
|
value,
|
|
onSelect,
|
|
onClose,
|
|
}: Props<T>) {
|
|
const insets = useSafeAreaInsets();
|
|
const colors = useColors();
|
|
const translateY = useRef(new Animated.Value(400)).current;
|
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
translateY.setValue(400);
|
|
backdropOpacity.setValue(0);
|
|
Animated.parallel([
|
|
Animated.timing(translateY, {
|
|
toValue: 0,
|
|
duration: 280,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(backdropOpacity, {
|
|
toValue: 1,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, translateY, backdropOpacity]);
|
|
|
|
function close() {
|
|
Animated.parallel([
|
|
Animated.timing(translateY, {
|
|
toValue: 400,
|
|
duration: 220,
|
|
easing: Easing.in(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(backdropOpacity, {
|
|
toValue: 0,
|
|
duration: 180,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => {
|
|
onClose();
|
|
});
|
|
}
|
|
|
|
const hasHeader = !!(title || message);
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="none" onRequestClose={close}>
|
|
{/* Backdrop */}
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
|
opacity: backdropOpacity,
|
|
}}
|
|
>
|
|
<Pressable style={{ flex: 1 }} onPress={close} />
|
|
</Animated.View>
|
|
|
|
{/* Sheet — bottom-aligned, horizontal margin, 2 separate cards */}
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
paddingHorizontal: 10,
|
|
paddingBottom: Math.max(insets.bottom, 10),
|
|
transform: [{ translateY }],
|
|
}}
|
|
>
|
|
{/* Options-Card */}
|
|
<View
|
|
style={{
|
|
backgroundColor: 'rgba(250,250,252,0.97)',
|
|
borderRadius: 14,
|
|
overflow: 'hidden',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
{hasHeader ? (
|
|
<View
|
|
style={{
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 16,
|
|
borderBottomWidth: 0.5,
|
|
borderBottomColor: 'rgba(60,60,67,0.36)',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{title ? (
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{title}
|
|
</Text>
|
|
) : null}
|
|
{message ? (
|
|
<Text
|
|
style={{
|
|
marginTop: 4,
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
textAlign: 'center',
|
|
lineHeight: 16,
|
|
}}
|
|
>
|
|
{message}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
|
|
{options.map((opt, idx) => {
|
|
const isLast = idx === options.length - 1;
|
|
const isSelected =
|
|
value !== null && value !== undefined && opt.value === value;
|
|
return (
|
|
<TouchableOpacity
|
|
key={String(opt.value)}
|
|
onPress={() => {
|
|
onSelect(opt.value);
|
|
close();
|
|
}}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View
|
|
style={{
|
|
paddingVertical: 17,
|
|
paddingHorizontal: 16,
|
|
borderBottomWidth: isLast ? 0 : 0.5,
|
|
borderBottomColor: 'rgba(60,60,67,0.36)',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 20,
|
|
color: opt.destructive ? colors.error : colors.brandOrange,
|
|
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{opt.label}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Cancel-Card — separat, bold */}
|
|
<TouchableOpacity onPress={close} activeOpacity={0.7}>
|
|
<View
|
|
style={{
|
|
backgroundColor: 'rgba(250,250,252,0.97)',
|
|
borderRadius: 14,
|
|
paddingVertical: 17,
|
|
paddingHorizontal: 16,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 20,
|
|
color: colors.brandOrange,
|
|
fontFamily: 'Nunito_700Bold',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Abbrechen
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
</Modal>
|
|
);
|
|
}
|