Rollback-Punkt vor Expo SDK 54 / RN 0.81 Upgrade. UI/UX: - Profile: ProfileHeader redesign (sign-in chip + member-since), StatsBar 3 pill cards, Demographics accordion completed (Geburtsjahr, Geschlecht, Familienstand, Beruf-split, Wohnort), Pro-Trial-Banner, Approved-Domains list, DigaMissionBanner - Settings: section-based layout, neutral icons (matched Header dropdown style) - Header dropdown: extended with logout + games-page link - Notifications page: skeleton dummy data - Locales: i18n keys for new screens New components: - WheelPickerModal: native iOS UIPickerView wheel for long lists (Geburtsjahr 91 items, Bundesland 16, Stadt 30+/Bundesland) - OptionsBottomSheet: iOS-style options sheet (used briefly for Geschlecht, currently unused — kept for potential future use) - germanCities.ts: Top-cities per Bundesland (DSGVO-clean static data) New libs (NewArch-codegen verified): - @react-native-menu/menu 2.0.0 (UIMenu wrapper, Apple HIG-konform) - @lodev09/react-native-true-sheet 3.10.1 (UISheetPresentationController wrapper — ABER incompatible mit RN 0.79.6, Build-Error → Trigger für SDK-54-Upgrade) Maestro E2E: - Initial setup mit auth/community/profile/urge flows Scripts: - build-ios-clean.sh: Xcode DerivedData + ios/build cleanup vor expo run:ios Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
6.7 KiB
TypeScript
245 lines
6.7 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,
|
|
Animated,
|
|
Easing,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { colors } 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 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 (
|
|
<Pressable
|
|
key={String(opt.value)}
|
|
onPress={() => {
|
|
onSelect(opt.value);
|
|
close();
|
|
}}
|
|
style={({ pressed }) => ({
|
|
backgroundColor: pressed ? 'rgba(0,0,0,0.06)' : 'transparent',
|
|
})}
|
|
>
|
|
<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>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Cancel-Card — separat, bold */}
|
|
<Pressable onPress={close}>
|
|
{({ pressed }) => (
|
|
<View
|
|
style={{
|
|
backgroundColor: pressed
|
|
? 'rgba(255,255,255,0.85)'
|
|
: '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>
|
|
)}
|
|
</Pressable>
|
|
</Animated.View>
|
|
</Modal>
|
|
);
|
|
}
|