feat(rebreak-native): phase 2 sheet standardisation — SheetFieldStack + FormSheet migrations

PostCommentsSheet:
- Fix Resize-Bug: PanResponder nur auf Grabber+Header, kein onStartShouldSetPanResponderCapture
  (das stahl Touch-Events von der FlatList und brach Drag-Resize)
- Height-Limits (MAX/MIN/INITIAL) als Refs in PanResponder-Closure, damit sie nicht
  auf den ersten-Render-Stand eingefroren werden
- Keyboard-Show/-Hide animiert currentHeight korrekt ohne den Resize-Referenzpunkt
  zu verlieren
- Avatar in CommentRow: resolveAvatar() wenn authorAvatar vorhanden, Initialen-Fallback
  sonst. Bereit sobald Backend authorAvatar in Comments-Response mitliefert.
- Alle Pressable durch TouchableOpacity ersetzt

SheetFieldStack (neu):
- Progressives Multi-Input-Pattern als FormSheet-Inhalt
- Ausgefüllte Felder werden als antippbare Chips (mit Stift-Icon) nach oben verschoben
- Aktives Feld: TextInput + →/✓-Button (letztes Feld = Checkmark)
- Validate + Normalize pro Feld, Fehleranzeige unter dem Input
- suffix-Slot für Eye-Toggle etc.
- Nach letztem Feld: Keyboard.dismiss() + children (Rest des Formulars) erscheint

Migriert auf FormSheet + SheetFieldStack:
- ConnectMailSheet: Grid-View unveraendert; Form-View (email+password) via SheetFieldStack;
  Zurück/Abbrechen-Header-Buttons entfernt (Schliessen = Swipe/Backdrop)
- EditMailAccountSheet: single-password-field via SheetFieldStack; Cancel-Header-Button weg
- AddDomainSheet: domain-field via SheetFieldStack; Favicon-Preview+Warning+Checkbox+Button
  als children; Cancel-Header-Button weg
- CreateRoomSheet: name+description via SheetFieldStack; Public-Toggle+JoinMode+Buttons
  als children; Abbrechen-Button bleibt (kein Header-Button, design-OK)

useSheetKeyboardLift: geloescht (keine Aufrufer mehr nach Migration)
KeyboardAwareSheet bleibt (AddMacSheet + AddWindowsSheet nutzen es noch)

tsc --noEmit: gruen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-12 21:37:46 +02:00
parent 448d64dbd5
commit 7ad523f8ba
7 changed files with 941 additions and 1026 deletions

View File

@ -5,12 +5,12 @@ import {
Modal,
FlatList,
TextInput,
Pressable,
TouchableOpacity,
Keyboard,
Platform,
ActivityIndicator,
Animated,
Image,
PanResponder,
useWindowDimensions,
} from 'react-native';
@ -20,11 +20,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { formatRelativeTime } from '../lib/formatTime';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
import type { CommunityComment } from '../stores/community';
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
const SNAP_THRESHOLD = 50;
type Props = {
postId: string | null;
@ -47,74 +47,84 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
// Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt
// `Dimensions.get` (statisch beim Modul-Load).
const { height: SCREEN_HEIGHT } = useWindowDimensions();
// App-Konvention: Sheets nie höher als 75 % vom Screen (auch beim Hochziehen / mit Tastatur).
// TODO(phase-1b): dieses Sheet auf <FormSheet> umstellen, statt die Magic-Numbers hier zu pflegen.
const COLLAPSED_HEIGHT = SCREEN_HEIGHT * 0.65;
const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.75;
const MAX_HEIGHT = SCREEN_HEIGHT * 0.75;
const MIN_HEIGHT = SCREEN_HEIGHT * 0.35;
const INITIAL_HEIGHT = SCREEN_HEIGHT * 0.65;
// Sheet-Höhe animiert (height-based, bottom: 0 fix → Input bleibt immer am Edge sichtbar).
// Plus separater translateY für die Dismiss-Slide-Animation (native).
const sheetHeight = useRef(new Animated.Value(COLLAPSED_HEIGHT)).current;
// Sheet-Höhe animiert (height-based, bottom: 0 fix).
// Separater translateY für die Dismiss-Slide-Animation (native driver).
const sheetHeight = useRef(new Animated.Value(INITIAL_HEIGHT)).current;
const dismissY = useRef(new Animated.Value(0)).current;
const currentHeight = useRef(COLLAPSED_HEIGHT);
const currentHeight = useRef(INITIAL_HEIGHT);
// PanResponder braucht stabile Refs auf die berechneten Limits,
// weil er nur einmal erstellt wird (useRef-Semantik).
const maxHeightRef = useRef(MAX_HEIGHT);
const minHeightRef = useRef(MIN_HEIGHT);
const screenHeightRef = useRef(SCREEN_HEIGHT);
useEffect(() => {
maxHeightRef.current = MAX_HEIGHT;
minHeightRef.current = MIN_HEIGHT;
screenHeightRef.current = SCREEN_HEIGHT;
}, [MAX_HEIGHT, MIN_HEIGHT, SCREEN_HEIGHT]);
const handleClose = useCallback(() => {
Keyboard.dismiss();
setText('');
setReplyTarget(null);
sheetHeight.setValue(COLLAPSED_HEIGHT);
sheetHeight.setValue(INITIAL_HEIGHT);
dismissY.setValue(0);
currentHeight.current = COLLAPSED_HEIGHT;
currentHeight.current = INITIAL_HEIGHT;
onClose();
}, [onClose, sheetHeight, dismissY]);
}, [onClose, sheetHeight, dismissY, INITIAL_HEIGHT]);
useEffect(() => {
if (visible) {
sheetHeight.setValue(COLLAPSED_HEIGHT);
sheetHeight.setValue(INITIAL_HEIGHT);
dismissY.setValue(0);
currentHeight.current = COLLAPSED_HEIGHT;
currentHeight.current = INITIAL_HEIGHT;
}
}, [visible, sheetHeight, dismissY]);
}, [visible, sheetHeight, dismissY, INITIAL_HEIGHT]);
// PanResponder NUR für Grabber-Bar + Header — nicht für den Content-Bereich.
// onStartShouldSetPanResponderCapture würde FlatList-Scroll brechen.
const panResponder = useRef(
PanResponder.create({
// Claim Gesture sofort, kein Wartet-bis-5px
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 4,
onPanResponderTerminationRequest: () => false,
onPanResponderMove: (_, g) => {
// Drag rauf (g.dy < 0) → height grösser. Drag runter → height kleiner.
const next = currentHeight.current - g.dy;
const clamped = Math.max(MIN_HEIGHT - 100, Math.min(EXPANDED_HEIGHT + 20, next));
const clamped = Math.max(
minHeightRef.current - 80,
Math.min(maxHeightRef.current + 16, next),
);
sheetHeight.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const finalH = currentHeight.current - g.dy;
const velocity = g.vy; // Pixel pro ms (negativ = nach oben, positiv = nach unten)
const v = g.vy;
// Unter MIN_HEIGHT oder schneller Flick nach unten → dismiss
if (finalH < MIN_HEIGHT || velocity > 1.5) {
if (finalH < minHeightRef.current || v > 1.5) {
Animated.timing(dismissY, {
toValue: SCREEN_HEIGHT,
toValue: screenHeightRef.current,
duration: 200,
useNativeDriver: true,
}).start(() => {
handleClose();
Keyboard.dismiss();
setText('');
setReplyTarget(null);
sheetHeight.setValue(INITIAL_HEIGHT);
dismissY.setValue(0);
currentHeight.current = INITIAL_HEIGHT;
onClose();
});
return;
}
// Schneller Flick nach oben → auf Maximum schnappen
let target = finalH;
if (velocity < -1.5) {
target = EXPANDED_HEIGHT;
}
// Clamp auf gültigen Bereich, sonst bleibt's wo der User losgelassen hat
const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target));
if (v < -1.5) target = maxHeightRef.current;
const clamped = Math.max(minHeightRef.current, Math.min(maxHeightRef.current, target));
Animated.spring(sheetHeight, {
toValue: clamped,
@ -131,16 +141,30 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
setKeyboardHeight(e.endCoordinates.height);
const h = e.endCoordinates.height;
setKeyboardHeight(h);
const expanded = Math.min(currentHeight.current + h, maxHeightRef.current);
Animated.spring(sheetHeight, {
toValue: expanded,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
Animated.spring(sheetHeight, {
toValue: currentHeight.current,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
}, [sheetHeight]);
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
queryKey: ['post-comments', postId],
@ -190,18 +214,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
[postId, queryClient],
);
// Bei offener Tastatur → automatisch expanded
useEffect(() => {
if (keyboardHeight > 0 && currentHeight.current !== EXPANDED_HEIGHT) {
Animated.spring(sheetHeight, {
toValue: EXPANDED_HEIGHT,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
currentHeight.current = EXPANDED_HEIGHT;
}
}, [keyboardHeight, sheetHeight]);
const dragHandlers = panResponder.panHandlers;
return (
<Modal
@ -211,16 +224,14 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
transparent
statusBarTranslucent
>
{/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */}
<Pressable
{/* Backdrop */}
<TouchableOpacity
activeOpacity={1}
onPress={handleClose}
style={{
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.12)',
}}
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.12)' }}
/>
{/* Outer: animated height (non-native driver) */}
{/* Outer: animated height (JS driver) */}
<Animated.View
style={{
position: 'absolute',
@ -230,7 +241,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
height: sheetHeight,
}}
>
{/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */}
{/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */}
<Animated.View
style={{
flex: 1,
@ -238,9 +249,6 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
// Android: windowSoftInputMode=adjustResize schrumpft schon das Window
// → KEIN paddingBottom mehr (sonst doppelter Offset, Drawer schießt nach oben).
// iOS: kein adjustResize-Equivalent, padding muss hier kompensieren.
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
@ -249,9 +257,9 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
shadowRadius: 8,
}}
>
{/* Drag-Bar — drag-down dismisst via PanResponder */}
{/* Grabber-Bar — drag-area */}
<View
{...panResponder.panHandlers}
{...dragHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View
@ -264,9 +272,9 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
/>
</View>
{/* Header — auch drag-area, kein X-Button */}
{/* Header — auch drag-area */}
<View
{...panResponder.panHandlers}
{...dragHandlers}
style={{
paddingHorizontal: 20,
paddingTop: 6,
@ -294,7 +302,13 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
<Text style={{ fontSize: 14, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
<Text
style={{
fontSize: 14,
color: '#a3a3a3',
fontFamily: 'Nunito_400Regular',
}}
>
{t('community.comments_empty')}
</Text>
</View>
@ -331,9 +345,9 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
}}
>
{EMOJIS.map((e) => (
<Pressable key={e} onPress={() => setText((t) => t + e)}>
<TouchableOpacity key={e} activeOpacity={0.7} onPress={() => setText((prev) => prev + e)}>
<Text style={{ fontSize: 22 }}>{e}</Text>
</Pressable>
</TouchableOpacity>
))}
</View>
@ -349,13 +363,15 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
backgroundColor: colors.surface,
}}
>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
<Text
style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
>
{t('community.reply_to')}{' '}
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
</Text>
<Pressable onPress={() => setReplyTarget(null)}>
<TouchableOpacity activeOpacity={0.7} onPress={() => setReplyTarget(null)}>
<Ionicons name="close-circle" size={16} color="#a3a3a3" />
</Pressable>
</TouchableOpacity>
</View>
)}
@ -366,8 +382,6 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 10,
// Bei offener Tastatur kleines Padding (kein Home-Indicator sichtbar),
// sonst Safe-Area
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
borderTopWidth: 1,
borderTopColor: colors.border,
@ -402,14 +416,16 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
activeOpacity={0.5}
style={{ opacity: !text.trim() || submitting ? 0.5 : 1 }}
>
<View style={{
<View
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}>
}}
>
{submitting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
@ -444,22 +460,42 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
onLike();
}, [heartScale, onLike]);
const avatarSize = isReply ? 24 : 32;
const avatarRadius = avatarSize / 2;
const resolvedAvatar = comment.authorAvatar
? resolveAvatar(comment.authorAvatar, comment.authorNickname ?? 'anonym')
: null;
return (
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
<View
style={{
width: isReply ? 24 : 32,
height: isReply ? 24 : 32,
borderRadius: isReply ? 12 : 16,
width: avatarSize,
height: avatarSize,
borderRadius: avatarRadius,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
overflow: 'hidden',
}}
>
{resolvedAvatar ? (
<Image
source={{ uri: resolvedAvatar }}
style={{ width: avatarSize, height: avatarSize, borderRadius: avatarRadius }}
/>
) : (
<Text
style={{
fontSize: isReply ? 9 : 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<View style={{ flex: 1, minWidth: 0 }}>
@ -478,20 +514,32 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
{comment.content}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
<Text
style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
>
{formatRelativeTime(comment.createdAt)}
</Text>
{!isReply && onReply && (
<Pressable onPress={onReply}>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
<TouchableOpacity activeOpacity={0.7} onPress={onReply}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('community.reply')}
</Text>
</Pressable>
</TouchableOpacity>
)}
</View>
</View>
<Pressable onPress={handleLikeWithPop} style={{ alignItems: 'center', gap: 2, paddingTop: 2 }}>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleLikeWithPop}
style={{ alignItems: 'center', gap: 2, paddingTop: 2 }}
>
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={comment.userLike ? 'heart' : 'heart-outline'}
@ -504,7 +552,7 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
{comment.likesCount}
</Text>
)}
</Pressable>
</TouchableOpacity>
</View>
);
}

View File

@ -0,0 +1,225 @@
import { ReactNode, useState } from 'react';
import {
Keyboard,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../lib/theme';
export type SheetField = {
key: string;
label: string;
placeholder?: string;
value: string;
onChangeText: (v: string) => void;
validate?: (v: string) => string | undefined;
normalize?: (v: string) => string;
keyboardType?: TextInput['props']['keyboardType'];
secureTextEntry?: boolean;
autoCapitalize?: TextInput['props']['autoCapitalize'];
autoCorrect?: boolean;
suffix?: ReactNode;
};
type Props = {
fields: SheetField[];
/** Rendert sich nach dem letzten Feld — sichtbar sobald alle Felder ausgefüllt sind. */
children?: ReactNode;
onComplete?: () => void;
};
/**
* Progressives Multi-Input-Pattern für FormSheet-Inhalte.
*
* Jeweils ein Feld ist aktiv (TextInput + /-Button). Bereits ausgefüllte
* Felder wandern als antippbare Chips nach oben. Tap auf Chip zurück zum
* Editieren. Nach dem letzten Feld: Keyboard.dismiss() + children werden sichtbar.
*
* Wird als `children` von `<FormSheet>` benutzt.
*/
export function SheetFieldStack({ fields, children, onComplete }: Props) {
const colors = useColors();
const [activeIndex, setActiveIndex] = useState(0);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const allDone = activeIndex >= fields.length;
function advanceOrFinish() {
const field = fields[activeIndex];
if (!field) return;
const error = field.validate?.(field.value);
if (error) {
setFieldErrors((prev) => ({ ...prev, [field.key]: error }));
return;
}
const normalized = field.normalize ? field.normalize(field.value) : field.value;
if (normalized !== field.value) {
field.onChangeText(normalized);
}
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field.key];
return next;
});
if (activeIndex === fields.length - 1) {
Keyboard.dismiss();
setActiveIndex(fields.length);
onComplete?.();
} else {
setActiveIndex((i) => i + 1);
}
}
function goToField(index: number) {
setActiveIndex(index);
}
const isLast = activeIndex === fields.length - 1;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 16, gap: 10 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Abgeschlossene Felder als Chips */}
{fields.slice(0, activeIndex).map((field, index) => (
<TouchableOpacity
key={field.key}
activeOpacity={0.7}
onPress={() => goToField(index)}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
}}
>
<View style={{ flex: 1 }}>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}
>
{field.label}
</Text>
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.text, marginTop: 1 }}
numberOfLines={1}
>
{field.secureTextEntry ? '••••••••' : field.value}
</Text>
</View>
<Ionicons name="pencil-outline" size={14} color={colors.textMuted} />
</TouchableOpacity>
))}
{/* Aktives Feld */}
{!allDone && (
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
marginBottom: 6,
}}
>
{fields[activeIndex].label}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
borderWidth: fieldErrors[fields[activeIndex].key] ? 1 : 0,
borderColor: '#dc2626',
}}
>
<TextInput
autoFocus
value={fields[activeIndex].value}
onChangeText={(v) => {
fields[activeIndex].onChangeText(v);
if (fieldErrors[fields[activeIndex].key]) {
setFieldErrors((prev) => {
const next = { ...prev };
delete next[fields[activeIndex].key];
return next;
});
}
}}
placeholder={fields[activeIndex].placeholder}
placeholderTextColor={colors.textMuted}
keyboardType={fields[activeIndex].keyboardType ?? 'default'}
secureTextEntry={fields[activeIndex].secureTextEntry}
autoCapitalize={fields[activeIndex].autoCapitalize ?? 'sentences'}
autoCorrect={fields[activeIndex].autoCorrect ?? true}
returnKeyType={isLast ? 'done' : 'next'}
onSubmitEditing={advanceOrFinish}
blurOnSubmit={false}
style={{
flex: 1,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
{fields[activeIndex].suffix}
</View>
<TouchableOpacity
activeOpacity={0.8}
onPress={advanceOrFinish}
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons
name={isLast ? 'checkmark' : 'arrow-forward'}
size={20}
color="#fff"
/>
</TouchableOpacity>
</View>
{fieldErrors[fields[activeIndex].key] && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginTop: 4,
}}
>
{fieldErrors[fields[activeIndex].key]}
</Text>
)}
</View>
)}
{/* Rest des Formulars — sichtbar wenn alle Felder durch */}
{allDone && children}
</ScrollView>
);
}

View File

@ -1,13 +1,11 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
Image,
ActivityIndicator,
Image,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import {
@ -16,9 +14,8 @@ import {
type Tier,
} from '../../hooks/useCustomDomains';
import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const COLLAPSED_HEIGHT = 600;
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
type Props = {
visible: boolean;
@ -30,24 +27,24 @@ type Props = {
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const [input, setInput] = useState('');
const [confirmPermanent, setConfirmPermanent] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldsDone, setFieldsDone] = useState(false);
const valid = isValidDomain(input);
const normalized = normalizeDomain(input);
function close() {
setInput('');
setConfirmPermanent(false);
setError(null);
setFieldsDone(false);
onClose();
}
async function handleAdd() {
if (!valid || !confirmPermanent || adding) return;
if (!isValidDomain(input) || !confirmPermanent || adding) return;
setAdding(true);
setError(null);
const result = await onAdd(input);
@ -68,96 +65,33 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
? t('blocker.add_sheet_warning_free')
: t('blocker.add_sheet_warning_pro');
const header = (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<TouchableOpacity onPress={close} hitSlop={10} activeOpacity={0.6}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('blocker.add_sheet_title')}
</Text>
<View style={{ width: 60 }} />
</View>
);
return (
<KeyboardAwareSheet
<FormSheet
visible={visible}
onClose={close}
collapsedHeight={COLLAPSED_HEIGHT}
header={header}
pushChildrenToBottom={false}
title={t('blocker.add_sheet_title')}
initialHeightPct={0.75}
growWithKeyboard
>
<View style={{ flex: 1, padding: 20, gap: 14 }}>
{/* Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
marginBottom: 6,
}}
<SheetFieldStack
fields={[
{
key: 'domain',
label: t('blocker.add_sheet_label'),
placeholder: t('blocker.add_sheet_placeholder'),
value: input,
onChangeText: (v) => { setInput(v); setError(null); },
normalize: normalizeDomain,
keyboardType: 'url',
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
isValidDomain(v) ? undefined : t('blocker.add_sheet_invalid'),
},
]}
onComplete={() => setFieldsDone(true)}
>
{t('blocker.add_sheet_label')}
</Text>
<TextInput
value={input}
onChangeText={(v) => {
setInput(v);
setError(null);
}}
onBlur={() => {
const n = normalizeDomain(input);
if (n !== input) setInput(n);
}}
placeholder={t('blocker.add_sheet_placeholder')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
keyboardType="url"
returnKeyType="done"
onSubmitEditing={handleAdd}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
{input && !valid && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginTop: 6,
}}
>
{t('blocker.add_sheet_invalid')}
</Text>
)}
</View>
{/* Preview */}
{valid && (
{/* Favicon-Preview */}
<View
style={{
flexDirection: 'row',
@ -166,6 +100,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
marginBottom: 12,
}}
>
<Image
@ -186,10 +121,8 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
{normalized}
</Text>
</View>
)}
{/* Warning */}
{valid && (
{/* Warnung */}
<View
style={{
flexDirection: 'row',
@ -199,6 +132,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
borderRadius: 12,
borderWidth: 1,
borderColor: '#fcd34d',
marginBottom: 12,
}}
>
<Ionicons name="lock-closed" size={18} color="#92400e" />
@ -214,10 +148,8 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
{warningText}
</Text>
</View>
)}
{/* Confirm-Checkbox */}
{valid && (
<TouchableOpacity
onPress={() => setConfirmPermanent((v) => !v)}
activeOpacity={0.7}
@ -226,6 +158,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
alignItems: 'flex-start',
gap: 10,
paddingVertical: 4,
marginBottom: 14,
}}
>
<View
@ -255,27 +188,30 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
{t('blocker.add_sheet_confirm_permanent')}
</Text>
</TouchableOpacity>
)}
{/* Error */}
{error && (
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginBottom: 10,
}}
>
{error}
</Text>
)}
<View style={{ flex: 1 }} />
{/* Add-Button */}
<TouchableOpacity
onPress={handleAdd}
disabled={!valid || !confirmPermanent || adding}
disabled={!confirmPermanent || adding}
activeOpacity={0.85}
style={{ marginBottom: insets.bottom > 0 ? 8 : 12 }}
style={{ marginBottom: 12 }}
>
<View
style={{
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
backgroundColor: !confirmPermanent ? '#d4d4d4' : '#dc2626',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
@ -290,7 +226,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
)}
</View>
</TouchableOpacity>
</View>
</KeyboardAwareSheet>
</SheetFieldStack>
</FormSheet>
);
}

View File

@ -1,18 +1,16 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const COLLAPSED_HEIGHT = 480;
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
type Props = {
visible: boolean;
@ -29,12 +27,14 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
const [isPublic, setIsPublic] = useState(true);
const [joinMode, setJoinMode] = useState<'approval' | 'invite_only'>('approval');
const [creating, setCreating] = useState(false);
const [fieldsDone, setFieldsDone] = useState(false);
function reset() {
setName('');
setDescription('');
setIsPublic(true);
setJoinMode('approval');
setFieldsDone(false);
}
function handleClose() {
@ -59,53 +59,60 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
onCreated(room);
reset();
onClose();
} catch (err: any) {
console.error('Room erstellen fehlgeschlagen:', err.message);
} catch {
// ignore — Server-Error wird in einem späteren Pass mit Feedback versehen
} finally {
setCreating(false);
}
}
return (
<KeyboardAwareSheet
<FormSheet
visible={visible}
onClose={handleClose}
collapsedHeight={COLLAPSED_HEIGHT}
pushChildrenToBottom={false}
title={t('chat.create_group')}
initialHeightPct={0.75}
topRadius={22}
growWithKeyboard
>
<SheetFieldStack
fields={[
{
key: 'name',
label: t('chat.room_name'),
placeholder: t('chat.room_name'),
value: name,
onChangeText: setName,
autoCapitalize: 'sentences',
autoCorrect: false,
validate: (v) => (v.trim().length === 0 ? ' ' : undefined),
},
{
key: 'description',
label: t('chat.room_description'),
placeholder: t('chat.room_description'),
value: description,
onChangeText: setDescription,
autoCapitalize: 'sentences',
},
]}
onComplete={() => setFieldsDone(true)}
>
{/* Public-Toggle */}
<TouchableOpacity
activeOpacity={0.7}
style={styles.toggleRow}
onPress={() => setIsPublic((v) => !v)}
>
<View style={{ flex: 1, paddingHorizontal: 18, paddingTop: 6 }}>
<Text style={styles.title}>{t('chat.create_group')}</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t('chat.room_name')}
placeholderTextColor="#a3a3a3"
style={styles.input}
maxLength={60}
/>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t('chat.room_description')}
placeholderTextColor="#a3a3a3"
multiline
style={[styles.input, { height: 70, textAlignVertical: 'top' }]}
maxLength={250}
/>
{/* Public toggle */}
<TouchableOpacity activeOpacity={0.7} style={styles.toggleRow} onPress={() => setIsPublic((v) => !v)}>
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
</View>
</TouchableOpacity>
{/* Join mode (private only) */}
{/* Join-Mode (nur bei privaten Gruppen) */}
{!isPublic && (
<View style={{ marginTop: 8 }}>
<View style={{ marginTop: 8, marginBottom: 4 }}>
<Text style={styles.subLabel}>{t('chat.join_mode')}</Text>
<View style={styles.modeRow}>
{(['approval', 'invite_only'] as const).map((mode) => (
@ -129,9 +136,7 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
</View>
)}
<View style={{ flex: 1 }} />
{/* Actions */}
{/* Action-Buttons — Abbrechen bleibt (kein Header-Button) */}
<View style={styles.actions}>
<TouchableOpacity activeOpacity={0.7} onPress={handleClose} style={styles.cancelBtn}>
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
@ -149,35 +154,20 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAwareSheet>
</SheetFieldStack>
</FormSheet>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
title: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: colors.text,
marginBottom: 14,
},
input: {
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.text,
marginBottom: 10,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 6,
paddingVertical: 8,
marginTop: 4,
marginBottom: 4,
},
toggleLabel: {
fontSize: 14,
@ -241,7 +231,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
},
actions: {
flexDirection: 'row',
marginTop: 4,
marginTop: 12,
marginBottom: 10,
},
cancelBtn: {

View File

@ -2,22 +2,18 @@ import { useState } from 'react';
import {
ActivityIndicator,
Linking,
Platform,
TouchableOpacity,
ScrollView,
Text,
TextInput,
TouchableOpacity,
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 { humanizeMailError } from '../../lib/mailErrors';
import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const COLLAPSED_HEIGHT = 600;
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
type Props = {
visible: boolean;
@ -86,16 +82,16 @@ const PROVIDERS: ProviderConfig[] = [
];
/**
* Bottom-Sheet (65% Screen-Höhe) zum Verbinden eines Postfachs.
* Bottom-Sheet 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)
* Zwei Ansichten im selben Sheet (kein Navigations-Header):
* 1. Provider-Grid (6 Tiles) Schließen via Swipe/Backdrop
* 2. Formular: Email + App-Passwort als SheetFieldStack,
* dann Datenschutz-Hinweis + Connect-Button
*/
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');
@ -104,6 +100,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [fieldsComplete, setFieldsComplete] = useState(false);
function handleClose() {
setView('grid');
@ -112,108 +109,213 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
setPassword('');
setPasswordVisible(false);
setFormError(null);
setFieldsComplete(false);
onClose();
}
function handleProviderSelect(provider: ProviderConfig) {
setSelectedProvider(provider);
setEmail('');
setPassword('');
setFormError(null);
setFieldsComplete(false);
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 };
const result = await connect(body);
const result = await connect({ email: email.trim(), password });
if (result.ok) {
handleClose();
onSuccess();
} else {
// Rohen Server-Error durch den humanen Mapper leiten.
// humanizeMailError klassifiziert IMAP-Fehlertexte → i18n-Key → t() → lesbarer Satz.
setFormError(t(humanizeMailError(result.error)));
}
}
// Wenn User Email tippt → Provider-Icon in Echtzeit updaten
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
const currentProvider = selectedProvider ?? null;
const header = (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
{view === 'form' ? (
<TouchableOpacity activeOpacity={0.7} onPress={handleBack} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.back')}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity activeOpacity={0.7} onPress={handleClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
)}
<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>
);
const sheetTitle =
view === 'form' && selectedProvider
? t(selectedProvider.labelKey)
: t('mail.connect_sheet_title');
return (
<KeyboardAwareSheet
<FormSheet
visible={visible}
onClose={handleClose}
collapsedHeight={COLLAPSED_HEIGHT}
header={header}
pushChildrenToBottom={false}
title={sheetTitle}
initialHeightPct={0.75}
growWithKeyboard
>
{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 ? t(humanizeMailError(connectError)) : null)}
connecting={connecting}
onConnect={handleConnect}
insets={insets}
t={t}
<SheetFieldStack
fields={[
{
key: 'email',
label: t('mail.form_email_label'),
placeholder: t('mail.form_email_placeholder'),
value: email,
onChangeText: (v) => { setEmail(v); setFormError(null); },
keyboardType: 'email-address',
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
},
{
key: 'password',
label: t('mail.form_password_label'),
placeholder: t('mail.form_password_placeholder'),
value: password,
onChangeText: (v) => { setPassword(v); setFormError(null); },
secureTextEntry: !passwordVisible,
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
suffix: (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setPasswordVisible((p) => !p)}
hitSlop={8}
>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color="#a3a3a3"
/>
</TouchableOpacity>
),
},
]}
onComplete={() => setFieldsComplete(true)}
>
{/* App-Password-Guide — über den Datenschutz-Hinweis */}
{selectedProvider && selectedProvider.id !== 'other' && (
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
marginBottom: 10,
}}
>
<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(selectedProvider.guideKey)}
</Text>
{selectedProvider.guideUrl.length > 0 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL(selectedProvider.guideUrl)}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</TouchableOpacity>
)}
</KeyboardAwareSheet>
</View>
</View>
)}
{/* Datenschutz-Hinweis */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: '#f0fdf4',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bbf7d0',
marginBottom: 10,
}}
>
<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>
{/* Fehler */}
{(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginBottom: 10,
}}
>
{formError ?? t(humanizeMailError(connectError))}
</Text>
)}
{/* Connect-Button */}
<TouchableOpacity
activeOpacity={0.85}
onPress={handleConnect}
disabled={connecting}
style={{ marginTop: 4, marginBottom: 12 }}
>
<View
style={{
backgroundColor: connecting ? '#d4d4d4' : '#007AFF',
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>
</TouchableOpacity>
</SheetFieldStack>
)}
</FormSheet>
);
}
@ -257,7 +359,8 @@ function ProviderGrid({
activeOpacity={0.7}
style={{ width: '47%' }}
>
<View style={{
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
@ -266,7 +369,8 @@ function ProviderGrid({
borderColor: colors.border,
borderRadius: 14,
padding: 14,
}}>
}}
>
<View
style={{
width: 36,
@ -295,253 +399,3 @@ function ProviderGrid({
</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"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
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 && (
<TouchableOpacity activeOpacity={0.7} onPress={() => Linking.openURL(provider.guideUrl)}>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</TouchableOpacity>
)}
</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,
}}
/>
<TouchableOpacity
activeOpacity={0.7}
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"
/>
</TouchableOpacity>
</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 */}
<TouchableOpacity
activeOpacity={0.85}
onPress={onConnect}
disabled={!canConnect}
style={{ 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>
</TouchableOpacity>
</ScrollView>
);
}

View File

@ -1,13 +1,12 @@
import { useState } from 'react';
import { ActivityIndicator, TouchableOpacity, Text, TextInput, View } from 'react-native';
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect } from '../../hooks/useMailConnect';
import { useColors } from '../../lib/theme';
import { humanizeMailError } from '../../lib/mailErrors';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
const COLLAPSED_HEIGHT = 280;
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
type Props = {
visible: boolean;
@ -22,25 +21,22 @@ type Props = {
*/
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const colors = useColors();
const { connect, connecting, error: connectError } = useMailConnect();
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [fieldsComplete, setFieldsComplete] = useState(false);
function handleClose() {
setPassword('');
setPasswordVisible(false);
setFormError(null);
setFieldsComplete(false);
onClose();
}
async function handleSave() {
if (!password.trim()) {
setFormError(t('mail.form_fields_required'));
return;
}
setFormError(null);
const result = await connect({ email, password });
if (result.ok) {
@ -51,88 +47,56 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
}
}
const header = (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<TouchableOpacity activeOpacity={0.7} onPress={handleClose} hitSlop={8}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('mail.edit_account_cancel')}
</Text>
</TouchableOpacity>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('mail.edit_account_title')}
</Text>
<View style={{ width: 60 }} />
</View>
);
return (
<KeyboardAwareSheet
<FormSheet
visible={visible}
onClose={handleClose}
collapsedHeight={COLLAPSED_HEIGHT}
header={header}
title={t('mail.edit_account_title')}
initialHeightPct={0.5}
growWithKeyboard
>
<View style={{ padding: 20, gap: 14 }}>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 18,
}}
<SheetFieldStack
fields={[
{
key: 'password',
label: t('mail.form_password_label'),
placeholder: t('mail.app_password_placeholder'),
value: password,
onChangeText: (v) => { setPassword(v); setFormError(null); },
secureTextEntry: !passwordVisible,
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
suffix: (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setPasswordVisible((p) => !p)}
hitSlop={8}
>
{t('mail.edit_account_subtitle', { email })}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
gap: 10,
}}
>
<Ionicons name="lock-closed-outline" size={16} color={colors.textMuted} />
<TextInput
value={password}
onChangeText={(v) => {
setPassword(v);
setFormError(null);
}}
placeholder={t('mail.app_password_placeholder')}
placeholderTextColor={colors.textMuted}
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
style={{
flex: 1,
paddingVertical: 14,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
<TouchableOpacity activeOpacity={0.7} onPress={() => setPasswordVisible((p) => !p)} hitSlop={8}>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={18}
color="#737373"
/>
</TouchableOpacity>
</View>
),
},
]}
onComplete={() => setFieldsComplete(true)}
>
{/* Hint + Error + Save-Button */}
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#6b7280',
lineHeight: 18,
marginBottom: 10,
}}
>
{t('mail.edit_account_subtitle', { email })}
</Text>
{(formError ?? connectError) && (
<View
@ -143,6 +107,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
flexDirection: 'row',
gap: 8,
alignItems: 'flex-start',
marginBottom: 10,
}}
>
<Ionicons name="alert-circle" size={16} color="#dc2626" style={{ marginTop: 1 }} />
@ -154,9 +119,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
color: '#dc2626',
}}
>
{formError
? formError
: t(humanizeMailError(connectError))}
{formError ?? t(humanizeMailError(connectError))}
</Text>
</View>
)}
@ -164,14 +127,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
<TouchableOpacity
activeOpacity={0.85}
onPress={handleSave}
disabled={!password.trim() || connecting}
style={{ marginTop: 4 }}
disabled={connecting}
style={{ marginTop: 4, marginBottom: 12 }}
>
<View
style={{
paddingVertical: 14,
borderRadius: 12,
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
backgroundColor: connecting ? '#bfdbfe' : '#007AFF',
alignItems: 'center',
}}
>
@ -184,7 +147,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
)}
</View>
</TouchableOpacity>
</View>
</KeyboardAwareSheet>
</SheetFieldStack>
</FormSheet>
);
}

View File

@ -1,101 +0,0 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, Platform } from 'react-native';
import { useKeyboardHeight } from './useKeyboardHeight';
/**
* App-weite Composable für Sheets/Modals mit TextInput.
*
* Liefert ein **kombiniertes Animated.Value** für `transform: [{ translateY }]`,
* das gleichzeitig:
* - die Slide-In/Out-Animation des Sheets ausführt (von unten reinkommend)
* - das Sheet automatisch über die Tastatur lifted wenn TextInput fokussiert
*
* Beide Animationen laufen im **native driver** (Performance + smoother als
* height-Animationen). Kein Driver-Mix, kein Bouncing.
*
* Pattern (verifiziert auf EditMailAccountSheet + GameOverScreen):
* ```tsx
* const sheetH = SCREEN_HEIGHT * 0.5;
* const lift = useSheetKeyboardLift({ visible, offscreenY: sheetH });
*
* <Modal visible={visible}>
* <Animated.View
* style={{
* position: 'absolute',
* bottom: 0,
* height: sheetH,
* transform: [{ translateY: lift.translateY }],
* }}
* >
* {form content with TextInput}
* </Animated.View>
* </Modal>
* ```
*
* Anti-Pattern (was schief ging): `height: animatedValue` + `transform: animatedValue`
* auf demselben Animated.View native-animated-module-Crash. Stattdessen feste
* height + nur translateY animieren.
*
* Anti-Pattern 2: `marginBottom: keyboardHeight` als JS-style + native transform
* im selben View Bouncing weil zwei verschiedene Threads layouten.
*
* Für FlatList-basierte Sheets (PostCommentsSheet) ist das Pattern anders:
* dort wächst die Sheet-Höhe selbst weil eine variable Liste drin ist. Diese
* Composable ist für FIXED-HEIGHT-Form-Sheets gedacht.
*/
export interface SheetKeyboardLiftOptions {
/** Ob das Sheet aktuell sichtbar ist. Nur dann läuft Slide-In an. */
visible: boolean;
/** Y-Offset des Sheets im verborgenen Zustand (typischerweise = SHEET_HEIGHT). */
offscreenY: number;
/** Slide-Dauer in ms. Default 280. */
slideDurationMs?: number;
}
export function useSheetKeyboardLift({
visible,
offscreenY,
slideDurationMs = 280,
}: SheetKeyboardLiftOptions) {
const keyboardHeight = useKeyboardHeight();
const slideY = useRef(new Animated.Value(offscreenY)).current;
const keyboardLift = useRef(new Animated.Value(0)).current;
// Slide-In bei visible-Wechsel
useEffect(() => {
if (visible) {
slideY.setValue(offscreenY);
Animated.timing(slideY, {
toValue: 0,
duration: slideDurationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}
}, [visible, offscreenY, slideDurationMs, slideY]);
// Keyboard-Lift (iOS only — Android adjustResize macht das im Manifest)
useEffect(() => {
if (Platform.OS !== 'ios') return;
Animated.timing(keyboardLift, {
toValue: keyboardHeight,
duration: 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [keyboardHeight, keyboardLift]);
return {
/** Direkt in `transform: [{ translateY }]` einsetzen. */
translateY: Animated.subtract(slideY, keyboardLift),
/** Manuelle Slide-Out-Animation (z.B. beim Close-Tap statt nur visible=false). */
slideOut: (cb?: () => void) =>
Animated.timing(slideY, {
toValue: offscreenY,
duration: 220,
useNativeDriver: true,
}).start(() => cb?.()),
/** Live keyboard-Höhe für extra Layout-Berechnungen wenn nötig. */
keyboardHeight,
};
}