chahinebrini 3c52d8869e feat(native): WIP checkpoint — Profile/Settings/Demographics + WheelPicker + Maestro
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>
2026-05-08 19:32:27 +02:00

241 lines
7.1 KiB
TypeScript

import { View, Text, Pressable, Modal } from 'react-native';
import { useRouter, type RelativePathString } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
// 2026-05-07: kein separates 3-Punkte-Icon).
//
// Card-Style mit:
// - SOS prominent oben (nur Wort "SOS" rot, Tagline neutral; ernste Sache,
// nicht mit Gaming/Profile in eine Liste werfen)
// - Profile · Settings · Games · [Debug DEV] in der Mitte
// - Abmelden unten, neutral (nicht rot — Recovery-tonal, kein Alarm)
type ItemKey = 'profile' | 'settings' | 'games' | 'debug';
type Item = {
key: ItemKey;
label: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
onSelect: () => void | Promise<void>;
};
type Props = {
visible: boolean;
onClose: () => void;
topOffset?: number;
};
export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props) {
const router = useRouter();
const { t } = useTranslation();
const { signOut } = useAuthStore();
function nav(path: RelativePathString) {
onClose();
router.push(path);
}
async function handleLogout() {
onClose();
await signOut();
router.replace('/' as RelativePathString);
}
const items: Item[] = [
{
key: 'profile',
label: t('headerMenu.profile'),
icon: 'person-outline',
onSelect: () => nav('/profile' as RelativePathString),
},
{
key: 'settings',
label: t('headerMenu.settings'),
icon: 'settings-outline',
onSelect: () => nav('/settings' as RelativePathString),
},
{
key: 'games',
label: t('headerMenu.games'),
icon: 'game-controller-outline',
onSelect: () => nav('/games' as RelativePathString),
},
];
if (__DEV__) {
items.push({
key: 'debug',
label: t('headerMenu.debug'),
icon: 'bug-outline',
onSelect: () => nav('/debug' as RelativePathString),
});
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={onClose}
>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
>
<View
onStartShouldSetResponder={() => true}
style={{
position: 'absolute',
top: topOffset,
right: 12,
backgroundColor: '#ffffff',
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
minWidth: 210,
overflow: 'hidden',
}}
>
{/* SOS prominent — separat, ernst-tonal, nur "SOS" rot */}
<Pressable
onPress={() => {
onClose();
router.push('/urge' as RelativePathString);
}}
android_ripple={{ color: '#fee2e2' }}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 16,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#fee2e2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}
>
<Ionicons name="heart" size={18} color="#dc2626" />
</View>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#dc2626',
}}
numberOfLines={1}
>
{t('appHeader.sosLabel')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 1,
}}
numberOfLines={1}
>
{t('appHeader.sosTagline')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
</View>
</Pressable>
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
{/* Profile · Settings · Games · [Debug DEV] */}
{items.map((item) => (
<Pressable
key={item.key}
onPress={() => {
onClose();
void item.onSelect();
}}
android_ripple={{ color: '#e5e7eb' }}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons
name={item.icon}
size={18}
color="#737373"
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
>
{item.label}
</Text>
</View>
</Pressable>
))}
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
{/* Abmelden — neutral, nicht rot */}
<Pressable
onPress={handleLogout}
android_ripple={{ color: '#e5e7eb' }}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons
name="log-out-outline"
size={18}
color="#737373"
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
>
{t('headerMenu.logout')}
</Text>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}