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

612 lines
17 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Easing,
KeyboardAvoidingView,
Linking,
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65;
type Props = {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
};
type ProviderConfig = {
id: MailProvider;
labelKey: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
color: string;
guideKey: string;
guideUrl: string;
};
const PROVIDERS: ProviderConfig[] = [
{
id: 'gmail',
labelKey: 'mail.provider_gmail',
icon: 'mail',
color: '#EA4335',
guideKey: 'mail.app_password_guide_gmail',
guideUrl: 'https://myaccount.google.com/apppasswords',
},
{
id: 'icloud',
labelKey: 'mail.provider_icloud',
icon: 'cloud',
color: '#007AFF',
guideKey: 'mail.app_password_guide_icloud',
guideUrl: 'https://appleid.apple.com/account/manage',
},
{
id: 'outlook',
labelKey: 'mail.provider_outlook',
icon: 'mail-open',
color: '#0078D4',
guideKey: 'mail.app_password_guide_outlook',
guideUrl: 'https://account.microsoft.com/security',
},
{
id: 'yahoo',
labelKey: 'mail.provider_yahoo',
icon: 'at',
color: '#7C3AED',
guideKey: 'mail.app_password_guide_yahoo',
guideUrl: 'https://login.yahoo.com/account/security',
},
{
id: 'gmx',
labelKey: 'mail.provider_gmx',
icon: 'mail-unread',
color: '#E87A22',
guideKey: 'mail.app_password_guide_gmx',
guideUrl: 'https://www.gmx.net/mail/security',
},
{
id: 'other',
labelKey: 'mail.provider_other',
icon: 'server',
color: '#737373',
guideKey: 'mail.app_password_guide_other',
guideUrl: '',
},
];
/**
* Bottom-Sheet (65% Screen-Höhe) zum Verbinden eines Postfachs.
*
* Zwei Ansichten im selben Sheet:
* 1. Provider-Grid (6 Tiles)
* 2. Formular-View: Email + App-Passwort + Guide-Link (nach Provider-Tap)
*/
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { connect, connecting, error: connectError } = useMailConnect();
const [view, setView] = useState<'grid' | 'form'>('grid');
const [selectedProvider, setSelectedProvider] = useState<ProviderConfig | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
translateY.setValue(SHEET_HEIGHT);
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: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
function handleClose() {
setView('grid');
setSelectedProvider(null);
setEmail('');
setPassword('');
setPasswordVisible(false);
setFormError(null);
onClose();
}
function handleProviderSelect(provider: ProviderConfig) {
setSelectedProvider(provider);
setView('form');
setFormError(null);
}
function handleBack() {
setView('grid');
setSelectedProvider(null);
setFormError(null);
}
async function handleConnect() {
if (!email.trim() || !password.trim()) {
setFormError(t('mail.form_fields_required'));
return;
}
setFormError(null);
const body: Parameters<typeof connect>[0] = { email: email.trim(), password };
// Für "other" Provider: User muss imapHost selbst eingeben — aktuell nicht
// unterstützt in dieser Sheet-Version. Custom-IMAP bleibt TODO für Phase 11.
// Provider-Detection passiert server-seitig via Email-Domain.
const result = await connect(body);
if (result.ok) {
handleClose();
onSuccess();
} else {
setFormError(result.error ?? t('mail.connect_failed'));
}
}
// Wenn User Email tippt → Provider-Icon in Echtzeit updaten
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
const currentProvider = selectedProvider ?? null;
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
{/* Backdrop */}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={handleClose} />
</Animated.View>
{/* Sheet */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-Handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
{view === 'form' ? (
<Pressable onPress={handleBack} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.back')}
</Text>
</Pressable>
) : (
<Pressable onPress={handleClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.cancel')}
</Text>
</Pressable>
)}
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{view === 'form' && currentProvider
? t(currentProvider.labelKey)
: t('mail.connect_sheet_title')}
</Text>
<View style={{ width: 60 }} />
</View>
{/* Content */}
{view === 'grid' ? (
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
) : (
<FormView
provider={currentProvider}
detectedProvider={detectedProvider}
email={email}
onEmailChange={(v) => { setEmail(v); setFormError(null); }}
password={password}
onPasswordChange={(v) => { setPassword(v); setFormError(null); }}
passwordVisible={passwordVisible}
onTogglePasswordVisible={() => setPasswordVisible((p) => !p)}
error={formError ?? connectError}
connecting={connecting}
onConnect={handleConnect}
insets={insets}
t={t}
/>
)}
</KeyboardAvoidingView>
</Animated.View>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Provider-Grid
// ---------------------------------------------------------------------------
function ProviderGrid({
providers,
onSelect,
t,
}: {
providers: ProviderConfig[];
onSelect: (p: ProviderConfig) => void;
t: (key: string) => string;
}) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 12 }}
showsVerticalScrollIndicator={false}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
marginBottom: 4,
lineHeight: 18,
}}
>
{t('mail.connect_sheet_subtitle')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
{providers.map((p) => (
<Pressable
key={p.id}
onPress={() => onSelect(p)}
style={({ pressed }) => ({
width: '47%',
opacity: pressed ? 0.7 : 1,
})}
>
<View style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#f9f9f9',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 14,
}}>
<View
style={{
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: p.color + '18',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={p.icon} size={18} color={p.color} />
</View>
<View style={{ flex: 1 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
numberOfLines={1}
>
{t(p.labelKey)}
</Text>
</View>
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
</View>
</Pressable>
))}
</View>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Formular (Email + App-Passwort)
// ---------------------------------------------------------------------------
type FormViewProps = {
provider: ProviderConfig | null;
detectedProvider: MailProvider | null;
email: string;
onEmailChange: (v: string) => void;
password: string;
onPasswordChange: (v: string) => void;
passwordVisible: boolean;
onTogglePasswordVisible: () => void;
error: string | null;
connecting: boolean;
onConnect: () => void;
insets: ReturnType<typeof useSafeAreaInsets>;
t: (key: string) => string;
};
function FormView({
provider,
email,
onEmailChange,
password,
onPasswordChange,
passwordVisible,
onTogglePasswordVisible,
error,
connecting,
onConnect,
insets,
t,
}: FormViewProps) {
const canConnect = email.trim().length > 0 && password.trim().length > 0 && !connecting;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 14 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* App-Password-Guide-Hinweis */}
{provider && provider.id !== 'other' && (
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}}
>
<Ionicons name="information-circle" size={18} color="#1d4ed8" style={{ marginTop: 1 }} />
<View style={{ flex: 1, gap: 4 }}>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#1e3a8a',
}}
>
{t('mail.app_password_required_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1d4ed8',
lineHeight: 17,
}}
>
{t(provider.guideKey)}
</Text>
{provider.guideUrl.length > 0 && (
<Pressable onPress={() => Linking.openURL(provider.guideUrl)}>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</Pressable>
)}
</View>
</View>
)}
{/* Email-Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
marginBottom: 6,
}}
>
{t('mail.form_email_label')}
</Text>
<TextInput
value={email}
onChangeText={onEmailChange}
placeholder={t('mail.form_email_placeholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="next"
style={{
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
</View>
{/* Passwort-Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
marginBottom: 6,
}}
>
{t('mail.form_password_label')}
</Text>
<View style={{ position: 'relative' }}>
<TextInput
value={password}
onChangeText={onPasswordChange}
placeholder={t('mail.form_password_placeholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={onConnect}
style={{
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
paddingRight: 46,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
<Pressable
onPress={onTogglePasswordVisible}
hitSlop={8}
style={{
position: 'absolute',
right: 12,
top: 0,
bottom: 0,
justifyContent: 'center',
}}
>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color="#a3a3a3"
/>
</Pressable>
</View>
</View>
{/* Datenschutz-Hinweis */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: '#f0fdf4',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bbf7d0',
}}
>
<Ionicons name="shield-checkmark" size={16} color="#16a34a" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#166534',
lineHeight: 17,
}}
>
{t('mail.form_privacy_note')}
</Text>
</View>
{/* Error */}
{error && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
}}
>
{error}
</Text>
)}
{/* Connect-Button */}
<Pressable
onPress={onConnect}
disabled={!canConnect}
style={({ pressed }) => ({
opacity: pressed ? 0.85 : 1,
marginTop: 4,
marginBottom: insets.bottom > 0 ? 8 : 12,
})}
>
<View style={{
backgroundColor: canConnect ? '#007AFF' : '#d4d4d4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}>
{connecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.form_connect_btn')}
</Text>
)}
</View>
</Pressable>
</ScrollView>
);
}