chahinebrini d7b15e231a feat(theme): Dark Mode Wave 2 — blocker, mail, chat, community, notifications, all remaining screens
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>
2026-05-09 14:51:02 +02:00

616 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';
import { useColors } from '../../lib/theme';
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 colors = useColors();
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: colors.bg,
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: colors.border }} />
</View>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
{view === 'form' ? (
<Pressable onPress={handleBack} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.back')}
</Text>
</Pressable>
) : (
<Pressable onPress={handleClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</Pressable>
)}
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{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;
}) {
const colors = useColors();
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 12 }}
showsVerticalScrollIndicator={false}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
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: colors.surface,
borderWidth: 1,
borderColor: colors.border,
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: colors.text }}
numberOfLines={1}
>
{t(p.labelKey)}
</Text>
</View>
<Ionicons name="chevron-forward" size={14} color={colors.border} />
</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 colors = useColors();
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: colors.textMuted,
marginBottom: 6,
}}
>
{t('mail.form_email_label')}
</Text>
<TextInput
value={email}
onChangeText={onEmailChange}
placeholder={t('mail.form_email_placeholder')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="next"
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
</View>
{/* Passwort-Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
marginBottom: 6,
}}
>
{t('mail.form_password_label')}
</Text>
<View style={{ position: 'relative' }}>
<TextInput
value={password}
onChangeText={onPasswordChange}
placeholder={t('mail.form_password_placeholder')}
placeholderTextColor={colors.textMuted}
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={onConnect}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
paddingRight: 46,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
<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>
);
}