diff --git a/apps/rebreak-native/components/PostCommentsSheet.tsx b/apps/rebreak-native/components/PostCommentsSheet.tsx index 1030009..90175f7 100644 --- a/apps/rebreak-native/components/PostCommentsSheet.tsx +++ b/apps/rebreak-native/components/PostCommentsSheet.tsx @@ -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 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({ 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 ( - {/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */} - - {/* Outer: animated height (non-native driver) */} + {/* Outer: animated height (JS driver) */} - {/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */} + {/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */} - {/* Drag-Bar — drag-down dismisst via PanResponder */} - + {/* Grabber-Bar — drag-area */} + + + + + {/* Header — auch drag-area */} + + + {t('community.comments_title')} + + + + {/* Comments-Liste */} + {isLoading ? ( + + + + ) : ( + item.id} + style={{ flex: 1 }} + contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }} + keyboardShouldPersistTaps="handled" + ListEmptyComponent={ + + + {t('community.comments_empty')} + + + } + renderItem={({ item: comment }) => ( + + { + setReplyTarget({ id: comment.id, nickname: comment.authorNickname }); + inputRef.current?.focus(); + }} + onLike={() => likeComment(comment)} + /> + {repliesFor(comment.id).map((reply) => ( + + likeComment(reply)} /> + + ))} + + )} + /> + )} + + {/* Emoji-Bar */} - - - {/* Header — auch drag-area, kein X-Button */} - - - {t('community.comments_title')} - - - - {/* Comments-Liste */} - {isLoading ? ( - - + > + {EMOJIS.map((e) => ( + setText((prev) => prev + e)}> + {e} + + ))} - ) : ( - item.id} - style={{ flex: 1 }} - contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }} - keyboardShouldPersistTaps="handled" - ListEmptyComponent={ - - - {t('community.comments_empty')} - - - } - renderItem={({ item: comment }) => ( - - { - setReplyTarget({ id: comment.id, nickname: comment.authorNickname }); - inputRef.current?.focus(); - }} - onLike={() => likeComment(comment)} - /> - {repliesFor(comment.id).map((reply) => ( - - likeComment(reply)} /> - - ))} - - )} - /> - )} - {/* Emoji-Bar */} - - {EMOJIS.map((e) => ( - setText((t) => t + e)}> - {e} - - ))} - + {/* Reply-Context */} + {replyTarget && ( + + + {t('community.reply_to')}{' '} + @{replyTarget.nickname} + + setReplyTarget(null)}> + + + + )} - {/* Reply-Context */} - {replyTarget && ( + {/* Input + Send-Button */} 0 ? 8 : Math.max(12, insets.bottom), + borderTopWidth: 1, + borderTopColor: colors.border, }} > - - {t('community.reply_to')}{' '} - @{replyTarget.nickname} - - setReplyTarget(null)}> - - + + + + {submitting ? ( + + ) : ( + + )} + + - )} - - {/* Input + Send-Button */} - 0 ? 8 : Math.max(12, insets.bottom), - borderTopWidth: 1, - borderTopColor: colors.border, - }} - > - - - - {submitting ? ( - - ) : ( - - )} - - - @@ -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 ( - - {(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()} - + {resolvedAvatar ? ( + + ) : ( + + {(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()} + + )} @@ -478,20 +514,32 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro {comment.content} - + {formatRelativeTime(comment.createdAt)} {!isReply && onReply && ( - - + + {t('community.reply')} - + )} - + )} - + ); } diff --git a/apps/rebreak-native/components/SheetFieldStack.tsx b/apps/rebreak-native/components/SheetFieldStack.tsx new file mode 100644 index 0000000..35febfb --- /dev/null +++ b/apps/rebreak-native/components/SheetFieldStack.tsx @@ -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 `` benutzt. + */ +export function SheetFieldStack({ fields, children, onComplete }: Props) { + const colors = useColors(); + const [activeIndex, setActiveIndex] = useState(0); + const [fieldErrors, setFieldErrors] = useState>({}); + 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 ( + + {/* Abgeschlossene Felder als Chips */} + {fields.slice(0, activeIndex).map((field, index) => ( + goToField(index)} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 10, + gap: 10, + }} + > + + + {field.label} + + + {field.secureTextEntry ? '••••••••' : field.value} + + + + + ))} + + {/* Aktives Feld */} + {!allDone && ( + + + {fields[activeIndex].label} + + + + { + 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} + + + + + + + {fieldErrors[fields[activeIndex].key] && ( + + {fieldErrors[fields[activeIndex].key]} + + )} + + )} + + {/* Rest des Formulars — sichtbar wenn alle Felder durch */} + {allDone && children} + + ); +} diff --git a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx index 23fea79..6173059 100644 --- a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx +++ b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx @@ -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(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,214 +65,153 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { ? t('blocker.add_sheet_warning_free') : t('blocker.add_sheet_warning_pro'); - const header = ( - - - - {t('common.cancel')} - - - - {t('blocker.add_sheet_title')} - - - - ); - return ( - - - {/* Input */} - + { 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)} + > + {/* Favicon-Preview */} + + - {t('blocker.add_sheet_label')} - - { - 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 && ( - - {t('blocker.add_sheet_invalid')} - - )} + numberOfLines={1} + > + {normalized} + - {/* Preview */} - {valid && ( - + + - - - {normalized} - - - )} - - {/* Warning */} - {valid && ( - - - - {warningText} - - - )} + {warningText} + + {/* Confirm-Checkbox */} - {valid && ( - setConfirmPermanent((v) => !v)} - activeOpacity={0.7} + setConfirmPermanent((v) => !v)} + activeOpacity={0.7} + style={{ + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + paddingVertical: 4, + marginBottom: 14, + }} + > + - - {confirmPermanent && } - - - {t('blocker.add_sheet_confirm_permanent')} - - - )} + {confirmPermanent && } + + + {t('blocker.add_sheet_confirm_permanent')} + + - {/* Error */} {error && ( - + {error} )} - - {/* Add-Button */} 0 ? 8 : 12 }} + style={{ marginBottom: 12 }} > - - + + ); } diff --git a/apps/rebreak-native/components/chat/CreateRoomSheet.tsx b/apps/rebreak-native/components/chat/CreateRoomSheet.tsx index 31a9da2..a5796be 100644 --- a/apps/rebreak-native/components/chat/CreateRoomSheet.tsx +++ b/apps/rebreak-native/components/chat/CreateRoomSheet.tsx @@ -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 ( - - - {t('chat.create_group')} - - - - - {/* Public toggle */} - setIsPublic((v) => !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 */} + setIsPublic((v) => !v)} + > {t('chat.public_room')} - {/* Join mode (private only) */} + {/* Join-Mode (nur bei privaten Gruppen) */} {!isPublic && ( - + {t('chat.join_mode')} {(['approval', 'invite_only'] as const).map((mode) => ( @@ -129,9 +136,7 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) { )} - - - {/* Actions */} + {/* Action-Buttons — Abbrechen bleibt (kein Header-Button) */} {t('common.cancel')} @@ -149,35 +154,20 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) { )} - - + + ); } function makeStyles(colors: ReturnType) { 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) { }, actions: { flexDirection: 'row', - marginTop: 4, + marginTop: 12, marginBottom: 10, }, cancelBtn: { diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx index 4a46be0..e746531 100644 --- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -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(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[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 === 'form' ? ( - - - {t('common.back')} - - - ) : ( - - - {t('common.cancel')} - - - )} - - {view === 'form' && currentProvider - ? t(currentProvider.labelKey) - : t('mail.connect_sheet_title')} - - - - ); + const sheetTitle = + view === 'form' && selectedProvider + ? t(selectedProvider.labelKey) + : t('mail.connect_sheet_title'); return ( - {view === 'grid' ? ( ) : ( - { 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} - /> + { 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: ( + setPasswordVisible((p) => !p)} + hitSlop={8} + > + + + ), + }, + ]} + onComplete={() => setFieldsComplete(true)} + > + {/* App-Password-Guide — über den Datenschutz-Hinweis */} + {selectedProvider && selectedProvider.id !== 'other' && ( + + + + + {t('mail.app_password_required_title')} + + + {t(selectedProvider.guideKey)} + + {selectedProvider.guideUrl.length > 0 && ( + Linking.openURL(selectedProvider.guideUrl)} + > + + {t('mail.app_password_open_link')} → + + + )} + + + )} + + {/* Datenschutz-Hinweis */} + + + + {t('mail.form_privacy_note')} + + + + {/* Fehler */} + {(formError ?? (connectError ? t(humanizeMailError(connectError)) : null)) && ( + + {formError ?? t(humanizeMailError(connectError))} + + )} + + {/* Connect-Button */} + + + {connecting ? ( + + ) : ( + + {t('mail.form_connect_btn')} + + )} + + + )} - + ); } @@ -257,37 +359,39 @@ function ProviderGrid({ activeOpacity={0.7} style={{ width: '47%' }} > - - - - - - {t(p.labelKey)} - - - + + + + + {t(p.labelKey)} + + + ))} @@ -295,253 +399,3 @@ function ProviderGrid({ ); } - -// --------------------------------------------------------------------------- -// 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; - 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 ( - - {/* App-Password-Guide-Hinweis */} - {provider && provider.id !== 'other' && ( - - - - - {t('mail.app_password_required_title')} - - - {t(provider.guideKey)} - - {provider.guideUrl.length > 0 && ( - Linking.openURL(provider.guideUrl)}> - - {t('mail.app_password_open_link')} → - - - )} - - - )} - - {/* Email-Input */} - - - {t('mail.form_email_label')} - - - - - {/* Passwort-Input */} - - - {t('mail.form_password_label')} - - - - - - - - - - {/* Datenschutz-Hinweis */} - - - - {t('mail.form_privacy_note')} - - - - {/* Error */} - {error && ( - - {error} - - )} - - {/* Connect-Button */} - 0 ? 8 : 12 }} - > - - {connecting ? ( - - ) : ( - - {t('mail.form_connect_btn')} - - )} - - - - ); -} diff --git a/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx b/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx index e0d11aa..e12c3a0 100644 --- a/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx +++ b/apps/rebreak-native/components/mail/EditMailAccountSheet.tsx @@ -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(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,89 +47,57 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro } } - const header = ( - - - - {t('mail.edit_account_cancel')} - - - - {t('mail.edit_account_title')} - - - - ); - return ( - - + { setPassword(v); setFormError(null); }, + secureTextEntry: !passwordVisible, + autoCapitalize: 'none', + autoCorrect: false, + validate: (v) => + v.trim().length === 0 ? t('mail.form_fields_required') : undefined, + suffix: ( + setPasswordVisible((p) => !p)} + hitSlop={8} + > + + + ), + }, + ]} + onComplete={() => setFieldsComplete(true)} + > + {/* Hint + Error + Save-Button */} {t('mail.edit_account_subtitle', { email })} - - - { - 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, - }} - /> - setPasswordVisible((p) => !p)} hitSlop={8}> - - - - {(formError ?? connectError) && ( @@ -154,9 +119,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro color: '#dc2626', }} > - {formError - ? formError - : t(humanizeMailError(connectError))} + {formError ?? t(humanizeMailError(connectError))} )} @@ -164,14 +127,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro @@ -184,7 +147,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro )} - - + + ); } diff --git a/apps/rebreak-native/hooks/useSheetKeyboardLift.ts b/apps/rebreak-native/hooks/useSheetKeyboardLift.ts deleted file mode 100644 index 06b2a16..0000000 --- a/apps/rebreak-native/hooks/useSheetKeyboardLift.ts +++ /dev/null @@ -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 }); - * - * - * - * {form content with TextInput} - * - * - * ``` - * - * 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, - }; -}