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,
- };
-}