Wave 2 = ALLE app-files die in Wave 1 noch hardcoded waren. Komplette App-weit
theme-aware-Migration jetzt durch. Legacy `import { colors }` flat export
vollständig eliminiert.
Migrated this wave:
Top-level Screens:
- app/urge.tsx (makeStyles factory mit ~20 colors)
- app/room.tsx + dm.tsx + games.tsx
- app/(app)/chat.tsx + mail.tsx + coach.tsx + notifications.tsx
- app/profile/[userId].tsx + profile/edit.tsx (INPUT_STYLE in body moved)
- app/debug.tsx + auth/callback.tsx
Blocker (7):
- AddDomainSheet, CooldownBanner, DeactivationExplainerSheet, DomainGrid,
ProtectionCard, ProtectionDetailsSheet, ProtectionLockedCard
Mail (3):
- ConnectMailSheet, EditMailAccountSheet, MailEmptyState
Chat (1):
- ChatBubble, ChatInput
Community/Posts/Notifications:
- PostCard, PostCardSkeleton, ComposeCard, PostCommentsSheet
- NotificationsDropdown
- StreakBadge (Nativewind classes durch inline dynamic styles ersetzt)
Reusable Sheets:
- WheelPickerModal, OptionsBottomSheet, DeviceLimitReachedSheet
Urge subsystem (5):
- InlineRatingDrawer, ShareSuccessDrawer, UrgeStats, SosFeedbackModal,
Breathing
Profile components:
- DigaMissionBanner
Pattern: useColors() hook in component body, makeStyles(colors) factory wo
StyleSheet.create vorher hardcoded war. 11 base-tokens (bg/surface/
surfaceElevated/border/text/textMuted/brandOrange/brandBlue/success/error/
warning) nutzen colors.light vs colors.dark scheme.
Bewusst NICHT migriert (semantic colors):
- DigaMissionBanner amber (#fffbeb, #854d0e) — DiGA-brand, nicht neutral
- Lyra-thinking #3b82f6 in urge.tsx — Lyra-brand-color
- scrollDownBtn #374151 — intentional dark floating-button
TS clean. Test: Settings → Theme → Dark — alle screens sollen jetzt dunkel
werden ohne white-flashes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
6.8 KiB
TypeScript
246 lines
6.8 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 { 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 (
|
|
<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>
|
|
);
|
|
}
|