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:
parent
448d64dbd5
commit
7ad523f8ba
@ -5,12 +5,12 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
FlatList,
|
FlatList,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
|
Image,
|
||||||
PanResponder,
|
PanResponder,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@ -20,11 +20,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { formatRelativeTime } from '../lib/formatTime';
|
import { formatRelativeTime } from '../lib/formatTime';
|
||||||
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import type { CommunityComment } from '../stores/community';
|
import type { CommunityComment } from '../stores/community';
|
||||||
|
|
||||||
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
|
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
|
||||||
const SNAP_THRESHOLD = 50;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
postId: string | null;
|
postId: string | null;
|
||||||
@ -47,74 +47,84 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
// Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt
|
// Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt
|
||||||
// `Dimensions.get` (statisch beim Modul-Load).
|
// `Dimensions.get` (statisch beim Modul-Load).
|
||||||
const { height: SCREEN_HEIGHT } = useWindowDimensions();
|
const { height: SCREEN_HEIGHT } = useWindowDimensions();
|
||||||
// App-Konvention: Sheets nie höher als 75 % vom Screen (auch beim Hochziehen / mit Tastatur).
|
const MAX_HEIGHT = SCREEN_HEIGHT * 0.75;
|
||||||
// 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 MIN_HEIGHT = SCREEN_HEIGHT * 0.35;
|
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).
|
// Sheet-Höhe animiert (height-based, bottom: 0 fix).
|
||||||
// Plus separater translateY für die Dismiss-Slide-Animation (native).
|
// Separater translateY für die Dismiss-Slide-Animation (native driver).
|
||||||
const sheetHeight = useRef(new Animated.Value(COLLAPSED_HEIGHT)).current;
|
const sheetHeight = useRef(new Animated.Value(INITIAL_HEIGHT)).current;
|
||||||
const dismissY = useRef(new Animated.Value(0)).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(() => {
|
const handleClose = useCallback(() => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
setText('');
|
setText('');
|
||||||
setReplyTarget(null);
|
setReplyTarget(null);
|
||||||
sheetHeight.setValue(COLLAPSED_HEIGHT);
|
sheetHeight.setValue(INITIAL_HEIGHT);
|
||||||
dismissY.setValue(0);
|
dismissY.setValue(0);
|
||||||
currentHeight.current = COLLAPSED_HEIGHT;
|
currentHeight.current = INITIAL_HEIGHT;
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose, sheetHeight, dismissY]);
|
}, [onClose, sheetHeight, dismissY, INITIAL_HEIGHT]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
sheetHeight.setValue(COLLAPSED_HEIGHT);
|
sheetHeight.setValue(INITIAL_HEIGHT);
|
||||||
dismissY.setValue(0);
|
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(
|
const panResponder = useRef(
|
||||||
PanResponder.create({
|
PanResponder.create({
|
||||||
// Claim Gesture sofort, kein Wartet-bis-5px
|
|
||||||
onStartShouldSetPanResponder: () => true,
|
onStartShouldSetPanResponder: () => true,
|
||||||
onStartShouldSetPanResponderCapture: () => true,
|
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 4,
|
||||||
onMoveShouldSetPanResponder: () => true,
|
|
||||||
onMoveShouldSetPanResponderCapture: () => true,
|
|
||||||
onPanResponderTerminationRequest: () => false,
|
onPanResponderTerminationRequest: () => false,
|
||||||
onPanResponderMove: (_, g) => {
|
onPanResponderMove: (_, g) => {
|
||||||
// Drag rauf (g.dy < 0) → height grösser. Drag runter → height kleiner.
|
|
||||||
const next = currentHeight.current - g.dy;
|
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);
|
sheetHeight.setValue(clamped);
|
||||||
},
|
},
|
||||||
onPanResponderRelease: (_, g) => {
|
onPanResponderRelease: (_, g) => {
|
||||||
const finalH = currentHeight.current - g.dy;
|
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 < minHeightRef.current || v > 1.5) {
|
||||||
if (finalH < MIN_HEIGHT || velocity > 1.5) {
|
|
||||||
Animated.timing(dismissY, {
|
Animated.timing(dismissY, {
|
||||||
toValue: SCREEN_HEIGHT,
|
toValue: screenHeightRef.current,
|
||||||
duration: 200,
|
duration: 200,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start(() => {
|
}).start(() => {
|
||||||
handleClose();
|
Keyboard.dismiss();
|
||||||
|
setText('');
|
||||||
|
setReplyTarget(null);
|
||||||
|
sheetHeight.setValue(INITIAL_HEIGHT);
|
||||||
|
dismissY.setValue(0);
|
||||||
|
currentHeight.current = INITIAL_HEIGHT;
|
||||||
|
onClose();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schneller Flick nach oben → auf Maximum schnappen
|
|
||||||
let target = finalH;
|
let target = finalH;
|
||||||
if (velocity < -1.5) {
|
if (v < -1.5) target = maxHeightRef.current;
|
||||||
target = EXPANDED_HEIGHT;
|
const clamped = Math.max(minHeightRef.current, Math.min(maxHeightRef.current, target));
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp auf gültigen Bereich, sonst bleibt's wo der User losgelassen hat
|
|
||||||
const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target));
|
|
||||||
|
|
||||||
Animated.spring(sheetHeight, {
|
Animated.spring(sheetHeight, {
|
||||||
toValue: clamped,
|
toValue: clamped,
|
||||||
@ -131,16 +141,30 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
const showSub = Keyboard.addListener(showEvent, (e) => {
|
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, () => {
|
const hideSub = Keyboard.addListener(hideEvent, () => {
|
||||||
setKeyboardHeight(0);
|
setKeyboardHeight(0);
|
||||||
|
Animated.spring(sheetHeight, {
|
||||||
|
toValue: currentHeight.current,
|
||||||
|
useNativeDriver: false,
|
||||||
|
friction: 9,
|
||||||
|
tension: 70,
|
||||||
|
}).start();
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
showSub.remove();
|
showSub.remove();
|
||||||
hideSub.remove();
|
hideSub.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [sheetHeight]);
|
||||||
|
|
||||||
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
|
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
|
||||||
queryKey: ['post-comments', postId],
|
queryKey: ['post-comments', postId],
|
||||||
@ -190,18 +214,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
[postId, queryClient],
|
[postId, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bei offener Tastatur → automatisch expanded
|
const dragHandlers = panResponder.panHandlers;
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -211,16 +224,14 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
transparent
|
transparent
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
{/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */}
|
{/* Backdrop */}
|
||||||
<Pressable
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
onPress={handleClose}
|
onPress={handleClose}
|
||||||
style={{
|
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.12)' }}
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.12)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Outer: animated height (non-native driver) */}
|
{/* Outer: animated height (JS driver) */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -230,7 +241,7 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
height: sheetHeight,
|
height: sheetHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */}
|
{/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -238,9 +249,6 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
overflow: 'hidden',
|
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,
|
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
|
||||||
transform: [{ translateY: dismissY }],
|
transform: [{ translateY: dismissY }],
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
@ -249,175 +257,183 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Drag-Bar — drag-down dismisst via PanResponder */}
|
{/* Grabber-Bar — drag-area */}
|
||||||
<View
|
<View
|
||||||
{...panResponder.panHandlers}
|
{...dragHandlers}
|
||||||
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
|
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
|
||||||
>
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Header — auch drag-area */}
|
||||||
|
<View
|
||||||
|
{...dragHandlers}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 6,
|
||||||
|
paddingBottom: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
|
{t('community.comments_title')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Comments-Liste */}
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={topLevel}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#a3a3a3',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('community.comments_empty')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: comment }) => (
|
||||||
|
<View>
|
||||||
|
<CommentRow
|
||||||
|
comment={comment}
|
||||||
|
onReply={() => {
|
||||||
|
setReplyTarget({ id: comment.id, nickname: comment.authorNickname });
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onLike={() => likeComment(comment)}
|
||||||
|
/>
|
||||||
|
{repliesFor(comment.id).map((reply) => (
|
||||||
|
<View key={reply.id} style={{ marginLeft: 44 }}>
|
||||||
|
<CommentRow comment={reply} isReply onLike={() => likeComment(reply)} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Emoji-Bar */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 36,
|
flexDirection: 'row',
|
||||||
height: 5,
|
justifyContent: 'space-around',
|
||||||
borderRadius: 3,
|
paddingHorizontal: 16,
|
||||||
backgroundColor: colors.border,
|
paddingVertical: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</View>
|
{EMOJIS.map((e) => (
|
||||||
|
<TouchableOpacity key={e} activeOpacity={0.7} onPress={() => setText((prev) => prev + e)}>
|
||||||
{/* Header — auch drag-area, kein X-Button */}
|
<Text style={{ fontSize: 22 }}>{e}</Text>
|
||||||
<View
|
</TouchableOpacity>
|
||||||
{...panResponder.panHandlers}
|
))}
|
||||||
style={{
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingTop: 6,
|
|
||||||
paddingBottom: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
||||||
{t('community.comments_title')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Comments-Liste */}
|
|
||||||
{isLoading ? (
|
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={topLevel}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
|
|
||||||
<Text style={{ fontSize: 14, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
|
|
||||||
{t('community.comments_empty')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
renderItem={({ item: comment }) => (
|
|
||||||
<View>
|
|
||||||
<CommentRow
|
|
||||||
comment={comment}
|
|
||||||
onReply={() => {
|
|
||||||
setReplyTarget({ id: comment.id, nickname: comment.authorNickname });
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}}
|
|
||||||
onLike={() => likeComment(comment)}
|
|
||||||
/>
|
|
||||||
{repliesFor(comment.id).map((reply) => (
|
|
||||||
<View key={reply.id} style={{ marginLeft: 44 }}>
|
|
||||||
<CommentRow comment={reply} isReply onLike={() => likeComment(reply)} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Emoji-Bar */}
|
{/* Reply-Context */}
|
||||||
<View
|
{replyTarget && (
|
||||||
style={{
|
<View
|
||||||
flexDirection: 'row',
|
style={{
|
||||||
justifyContent: 'space-around',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 16,
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
justifyContent: 'space-between',
|
||||||
borderTopWidth: 1,
|
paddingHorizontal: 16,
|
||||||
borderTopColor: colors.border,
|
paddingVertical: 8,
|
||||||
}}
|
backgroundColor: colors.surface,
|
||||||
>
|
}}
|
||||||
{EMOJIS.map((e) => (
|
>
|
||||||
<Pressable key={e} onPress={() => setText((t) => t + e)}>
|
<Text
|
||||||
<Text style={{ fontSize: 22 }}>{e}</Text>
|
style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
|
||||||
</Pressable>
|
>
|
||||||
))}
|
{t('community.reply_to')}{' '}
|
||||||
</View>
|
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity activeOpacity={0.7} onPress={() => setReplyTarget(null)}>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#a3a3a3" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Reply-Context */}
|
{/* Input + Send-Button */}
|
||||||
{replyTarget && (
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
paddingTop: 10,
|
||||||
backgroundColor: colors.surface,
|
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
<TextInput
|
||||||
{t('community.reply_to')}{' '}
|
ref={inputRef}
|
||||||
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
|
value={text}
|
||||||
</Text>
|
onChangeText={setText}
|
||||||
<Pressable onPress={() => setReplyTarget(null)}>
|
placeholder={t('community.comment_placeholder')}
|
||||||
<Ionicons name="close-circle" size={16} color="#a3a3a3" />
|
placeholderTextColor={colors.textMuted}
|
||||||
</Pressable>
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.text,
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
returnKeyType="send"
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={submit}
|
||||||
|
disabled={!text.trim() || submitting}
|
||||||
|
activeOpacity={0.5}
|
||||||
|
style={{ opacity: !text.trim() || submitting ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="paper-plane" size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input + Send-Button */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
value={text}
|
|
||||||
onChangeText={setText}
|
|
||||||
placeholder={t('community.comment_placeholder')}
|
|
||||||
placeholderTextColor={colors.textMuted}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 10,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: colors.text,
|
|
||||||
marginRight: 8,
|
|
||||||
}}
|
|
||||||
returnKeyType="send"
|
|
||||||
onSubmitEditing={submit}
|
|
||||||
blurOnSubmit={false}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={submit}
|
|
||||||
disabled={!text.trim() || submitting}
|
|
||||||
activeOpacity={0.5}
|
|
||||||
style={{ opacity: !text.trim() || submitting ? 0.5 : 1 }}
|
|
||||||
>
|
|
||||||
<View style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: colors.brandOrange,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{submitting ? (
|
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="paper-plane" size={16} color="#fff" />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -444,22 +460,42 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
onLike();
|
onLike();
|
||||||
}, [heartScale, onLike]);
|
}, [heartScale, onLike]);
|
||||||
|
|
||||||
|
const avatarSize = isReply ? 24 : 32;
|
||||||
|
const avatarRadius = avatarSize / 2;
|
||||||
|
const resolvedAvatar = comment.authorAvatar
|
||||||
|
? resolveAvatar(comment.authorAvatar, comment.authorNickname ?? 'anonym')
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
|
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: isReply ? 24 : 32,
|
width: avatarSize,
|
||||||
height: isReply ? 24 : 32,
|
height: avatarSize,
|
||||||
borderRadius: isReply ? 12 : 16,
|
borderRadius: avatarRadius,
|
||||||
backgroundColor: colors.surfaceElevated,
|
backgroundColor: colors.surfaceElevated,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
|
{resolvedAvatar ? (
|
||||||
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
|
<Image
|
||||||
</Text>
|
source={{ uri: resolvedAvatar }}
|
||||||
|
style={{ width: avatarSize, height: avatarSize, borderRadius: avatarRadius }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: isReply ? 9 : 11,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1, minWidth: 0 }}>
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
@ -478,20 +514,32 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
{comment.content}
|
{comment.content}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
|
<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)}
|
{formatRelativeTime(comment.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
{!isReply && onReply && (
|
{!isReply && onReply && (
|
||||||
<Pressable onPress={onReply}>
|
<TouchableOpacity activeOpacity={0.7} onPress={onReply}>
|
||||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('community.reply')}
|
{t('community.reply')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</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 }] }}>
|
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={comment.userLike ? 'heart' : 'heart-outline'}
|
name={comment.userLike ? 'heart' : 'heart-outline'}
|
||||||
@ -504,7 +552,7 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
{comment.likesCount}
|
{comment.likesCount}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
225
apps/rebreak-native/components/SheetFieldStack.tsx
Normal file
225
apps/rebreak-native/components/SheetFieldStack.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@ -16,9 +14,8 @@ import {
|
|||||||
type Tier,
|
type Tier,
|
||||||
} from '../../hooks/useCustomDomains';
|
} from '../../hooks/useCustomDomains';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
|
import { SheetFieldStack } from '../SheetFieldStack';
|
||||||
const COLLAPSED_HEIGHT = 600;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -30,24 +27,24 @@ type Props = {
|
|||||||
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
const [confirmPermanent, setConfirmPermanent] = useState(false);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldsDone, setFieldsDone] = useState(false);
|
||||||
|
|
||||||
const valid = isValidDomain(input);
|
|
||||||
const normalized = normalizeDomain(input);
|
const normalized = normalizeDomain(input);
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
setInput('');
|
setInput('');
|
||||||
setConfirmPermanent(false);
|
setConfirmPermanent(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFieldsDone(false);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!valid || !confirmPermanent || adding) return;
|
if (!isValidDomain(input) || !confirmPermanent || adding) return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await onAdd(input);
|
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_free')
|
||||||
: t('blocker.add_sheet_warning_pro');
|
: 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 (
|
return (
|
||||||
<KeyboardAwareSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
collapsedHeight={COLLAPSED_HEIGHT}
|
title={t('blocker.add_sheet_title')}
|
||||||
header={header}
|
initialHeightPct={0.75}
|
||||||
pushChildrenToBottom={false}
|
growWithKeyboard
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, padding: 20, gap: 14 }}>
|
<SheetFieldStack
|
||||||
{/* Input */}
|
fields={[
|
||||||
<View>
|
{
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
{/* Favicon-Preview */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `https://www.google.com/s2/favicons?domain=${normalized}&sz=64`,
|
||||||
|
}}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: colors.textMuted,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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,
|
color: colors.text,
|
||||||
}}
|
}}
|
||||||
/>
|
numberOfLines={1}
|
||||||
{input && !valid && (
|
>
|
||||||
<Text
|
{normalized}
|
||||||
style={{
|
</Text>
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: '#dc2626',
|
|
||||||
marginTop: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('blocker.add_sheet_invalid')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Preview */}
|
{/* Warnung */}
|
||||||
{valid && (
|
<View
|
||||||
<View
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#fef3c7',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#fcd34d',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="lock-closed" size={18} color="#92400e" />
|
||||||
|
<Text
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flex: 1,
|
||||||
alignItems: 'center',
|
fontSize: 12,
|
||||||
gap: 10,
|
fontFamily: 'Nunito_400Regular',
|
||||||
padding: 12,
|
color: '#92400e',
|
||||||
backgroundColor: colors.surfaceElevated,
|
lineHeight: 17,
|
||||||
borderRadius: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{warningText}
|
||||||
source={{
|
</Text>
|
||||||
uri: `https://www.google.com/s2/favicons?domain=${normalized}&sz=64`,
|
</View>
|
||||||
}}
|
|
||||||
style={{ width: 24, height: 24, borderRadius: 4 }}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
color: colors.text,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{normalized}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warning */}
|
|
||||||
{valid && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 10,
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: '#fef3c7',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#fcd34d',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="lock-closed" size={18} color="#92400e" />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: '#92400e',
|
|
||||||
lineHeight: 17,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{warningText}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirm-Checkbox */}
|
{/* Confirm-Checkbox */}
|
||||||
{valid && (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
onPress={() => setConfirmPermanent((v) => !v)}
|
||||||
onPress={() => setConfirmPermanent((v) => !v)}
|
activeOpacity={0.7}
|
||||||
activeOpacity={0.7}
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
width: 22,
|
||||||
alignItems: 'flex-start',
|
height: 22,
|
||||||
gap: 10,
|
borderRadius: 6,
|
||||||
paddingVertical: 4,
|
borderWidth: 1.5,
|
||||||
|
borderColor: confirmPermanent ? colors.success : colors.border,
|
||||||
|
backgroundColor: confirmPermanent ? colors.success : colors.bg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
|
||||||
style={{
|
</View>
|
||||||
width: 22,
|
<Text
|
||||||
height: 22,
|
style={{
|
||||||
borderRadius: 6,
|
flex: 1,
|
||||||
borderWidth: 1.5,
|
fontSize: 13,
|
||||||
borderColor: confirmPermanent ? colors.success : colors.border,
|
fontFamily: 'Nunito_400Regular',
|
||||||
backgroundColor: confirmPermanent ? colors.success : colors.bg,
|
color: colors.text,
|
||||||
alignItems: 'center',
|
lineHeight: 18,
|
||||||
justifyContent: 'center',
|
}}
|
||||||
marginTop: 1,
|
>
|
||||||
}}
|
{t('blocker.add_sheet_confirm_permanent')}
|
||||||
>
|
</Text>
|
||||||
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: colors.text,
|
|
||||||
lineHeight: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('blocker.add_sheet_confirm_permanent')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#dc2626',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
{/* Add-Button */}
|
{/* Add-Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleAdd}
|
onPress={handleAdd}
|
||||||
disabled={!valid || !confirmPermanent || adding}
|
disabled={!confirmPermanent || adding}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
style={{ marginBottom: insets.bottom > 0 ? 8 : 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
backgroundColor: !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -290,7 +226,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</SheetFieldStack>
|
||||||
</KeyboardAwareSheet>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
|
import { SheetFieldStack } from '../SheetFieldStack';
|
||||||
const COLLAPSED_HEIGHT = 480;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -29,12 +27,14 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
const [isPublic, setIsPublic] = useState(true);
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
const [joinMode, setJoinMode] = useState<'approval' | 'invite_only'>('approval');
|
const [joinMode, setJoinMode] = useState<'approval' | 'invite_only'>('approval');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [fieldsDone, setFieldsDone] = useState(false);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setIsPublic(true);
|
setIsPublic(true);
|
||||||
setJoinMode('approval');
|
setJoinMode('approval');
|
||||||
|
setFieldsDone(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@ -59,53 +59,60 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
onCreated(room);
|
onCreated(room);
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
console.error('Room erstellen fehlgeschlagen:', err.message);
|
// ignore — Server-Error wird in einem späteren Pass mit Feedback versehen
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAwareSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
collapsedHeight={COLLAPSED_HEIGHT}
|
title={t('chat.create_group')}
|
||||||
pushChildrenToBottom={false}
|
initialHeightPct={0.75}
|
||||||
topRadius={22}
|
topRadius={22}
|
||||||
|
growWithKeyboard
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, paddingHorizontal: 18, paddingTop: 6 }}>
|
<SheetFieldStack
|
||||||
<Text style={styles.title}>{t('chat.create_group')}</Text>
|
fields={[
|
||||||
|
{
|
||||||
<TextInput
|
key: 'name',
|
||||||
value={name}
|
label: t('chat.room_name'),
|
||||||
onChangeText={setName}
|
placeholder: t('chat.room_name'),
|
||||||
placeholder={t('chat.room_name')}
|
value: name,
|
||||||
placeholderTextColor="#a3a3a3"
|
onChangeText: setName,
|
||||||
style={styles.input}
|
autoCapitalize: 'sentences',
|
||||||
maxLength={60}
|
autoCorrect: false,
|
||||||
/>
|
validate: (v) => (v.trim().length === 0 ? ' ' : undefined),
|
||||||
<TextInput
|
},
|
||||||
value={description}
|
{
|
||||||
onChangeText={setDescription}
|
key: 'description',
|
||||||
placeholder={t('chat.room_description')}
|
label: t('chat.room_description'),
|
||||||
placeholderTextColor="#a3a3a3"
|
placeholder: t('chat.room_description'),
|
||||||
multiline
|
value: description,
|
||||||
style={[styles.input, { height: 70, textAlignVertical: 'top' }]}
|
onChangeText: setDescription,
|
||||||
maxLength={250}
|
autoCapitalize: 'sentences',
|
||||||
/>
|
},
|
||||||
|
]}
|
||||||
{/* Public toggle */}
|
onComplete={() => setFieldsDone(true)}
|
||||||
<TouchableOpacity activeOpacity={0.7} style={styles.toggleRow} onPress={() => setIsPublic((v) => !v)}>
|
>
|
||||||
|
{/* Public-Toggle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={styles.toggleRow}
|
||||||
|
onPress={() => setIsPublic((v) => !v)}
|
||||||
|
>
|
||||||
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
|
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
|
||||||
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
|
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
|
||||||
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
|
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Join mode (private only) */}
|
{/* Join-Mode (nur bei privaten Gruppen) */}
|
||||||
{!isPublic && (
|
{!isPublic && (
|
||||||
<View style={{ marginTop: 8 }}>
|
<View style={{ marginTop: 8, marginBottom: 4 }}>
|
||||||
<Text style={styles.subLabel}>{t('chat.join_mode')}</Text>
|
<Text style={styles.subLabel}>{t('chat.join_mode')}</Text>
|
||||||
<View style={styles.modeRow}>
|
<View style={styles.modeRow}>
|
||||||
{(['approval', 'invite_only'] as const).map((mode) => (
|
{(['approval', 'invite_only'] as const).map((mode) => (
|
||||||
@ -129,9 +136,7 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={{ flex: 1 }} />
|
{/* Action-Buttons — Abbrechen bleibt (kein Header-Button) */}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<TouchableOpacity activeOpacity={0.7} onPress={handleClose} style={styles.cancelBtn}>
|
<TouchableOpacity activeOpacity={0.7} onPress={handleClose} style={styles.cancelBtn}>
|
||||||
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
|
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
|
||||||
@ -149,35 +154,20 @@ export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</SheetFieldStack>
|
||||||
</KeyboardAwareSheet>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(colors: ReturnType<typeof useColors>) {
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||||
return StyleSheet.create({
|
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: {
|
toggleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 6,
|
paddingVertical: 8,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
toggleLabel: {
|
toggleLabel: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@ -241,7 +231,7 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 4,
|
marginTop: 12,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
cancelBtn: {
|
cancelBtn: {
|
||||||
|
|||||||
@ -2,22 +2,18 @@ import { useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Linking,
|
Linking,
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
|
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
|
||||||
import { humanizeMailError } from '../../lib/mailErrors';
|
import { humanizeMailError } from '../../lib/mailErrors';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
|
import { SheetFieldStack } from '../SheetFieldStack';
|
||||||
const COLLAPSED_HEIGHT = 600;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
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:
|
* Zwei Ansichten im selben Sheet (kein Navigations-Header):
|
||||||
* 1. Provider-Grid (6 Tiles)
|
* 1. Provider-Grid (6 Tiles) — Schließen via Swipe/Backdrop
|
||||||
* 2. Formular-View: Email + App-Passwort + Guide-Link (nach Provider-Tap)
|
* 2. Formular: Email + App-Passwort als SheetFieldStack,
|
||||||
|
* dann Datenschutz-Hinweis + Connect-Button
|
||||||
*/
|
*/
|
||||||
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { connect, connecting, error: connectError } = useMailConnect();
|
const { connect, connecting, error: connectError } = useMailConnect();
|
||||||
|
|
||||||
const [view, setView] = useState<'grid' | 'form'>('grid');
|
const [view, setView] = useState<'grid' | 'form'>('grid');
|
||||||
@ -104,6 +100,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [fieldsComplete, setFieldsComplete] = useState(false);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setView('grid');
|
setView('grid');
|
||||||
@ -112,108 +109,213 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
setPassword('');
|
setPassword('');
|
||||||
setPasswordVisible(false);
|
setPasswordVisible(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setFieldsComplete(false);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProviderSelect(provider: ProviderConfig) {
|
function handleProviderSelect(provider: ProviderConfig) {
|
||||||
setSelectedProvider(provider);
|
setSelectedProvider(provider);
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setFormError(null);
|
||||||
|
setFieldsComplete(false);
|
||||||
setView('form');
|
setView('form');
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
setView('grid');
|
|
||||||
setSelectedProvider(null);
|
|
||||||
setFormError(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnect() {
|
async function handleConnect() {
|
||||||
if (!email.trim() || !password.trim()) {
|
|
||||||
setFormError(t('mail.form_fields_required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
const result = await connect({ email: email.trim(), password });
|
||||||
const body: Parameters<typeof connect>[0] = { email: email.trim(), password };
|
|
||||||
|
|
||||||
const result = await connect(body);
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
handleClose();
|
handleClose();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
// Rohen Server-Error durch den humanen Mapper leiten.
|
|
||||||
// humanizeMailError klassifiziert IMAP-Fehlertexte → i18n-Key → t() → lesbarer Satz.
|
|
||||||
setFormError(t(humanizeMailError(result.error)));
|
setFormError(t(humanizeMailError(result.error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn User Email tippt → Provider-Icon in Echtzeit updaten
|
const sheetTitle =
|
||||||
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
|
view === 'form' && selectedProvider
|
||||||
const currentProvider = selectedProvider ?? null;
|
? t(selectedProvider.labelKey)
|
||||||
|
: t('mail.connect_sheet_title');
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAwareSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
collapsedHeight={COLLAPSED_HEIGHT}
|
title={sheetTitle}
|
||||||
header={header}
|
initialHeightPct={0.75}
|
||||||
pushChildrenToBottom={false}
|
growWithKeyboard
|
||||||
>
|
>
|
||||||
{view === 'grid' ? (
|
{view === 'grid' ? (
|
||||||
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
|
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
|
||||||
) : (
|
) : (
|
||||||
<FormView
|
<SheetFieldStack
|
||||||
provider={currentProvider}
|
fields={[
|
||||||
detectedProvider={detectedProvider}
|
{
|
||||||
email={email}
|
key: 'email',
|
||||||
onEmailChange={(v) => { setEmail(v); setFormError(null); }}
|
label: t('mail.form_email_label'),
|
||||||
password={password}
|
placeholder: t('mail.form_email_placeholder'),
|
||||||
onPasswordChange={(v) => { setPassword(v); setFormError(null); }}
|
value: email,
|
||||||
passwordVisible={passwordVisible}
|
onChangeText: (v) => { setEmail(v); setFormError(null); },
|
||||||
onTogglePasswordVisible={() => setPasswordVisible((p) => !p)}
|
keyboardType: 'email-address',
|
||||||
error={formError ?? (connectError ? t(humanizeMailError(connectError)) : null)}
|
autoCapitalize: 'none',
|
||||||
connecting={connecting}
|
autoCorrect: false,
|
||||||
onConnect={handleConnect}
|
validate: (v) =>
|
||||||
insets={insets}
|
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
|
||||||
t={t}
|
},
|
||||||
/>
|
{
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
</KeyboardAwareSheet>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,37 +359,39 @@ function ProviderGrid({
|
|||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={{ width: '47%' }}
|
style={{ width: '47%' }}
|
||||||
>
|
>
|
||||||
<View style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: 14,
|
|
||||||
}}>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 36,
|
flexDirection: 'row',
|
||||||
height: 36,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: p.color + '18',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 10,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name={p.icon} size={18} color={p.color} />
|
<View
|
||||||
</View>
|
style={{
|
||||||
<View style={{ flex: 1 }}>
|
width: 36,
|
||||||
<Text
|
height: 36,
|
||||||
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
borderRadius: 10,
|
||||||
numberOfLines={1}
|
backgroundColor: p.color + '18',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t(p.labelKey)}
|
<Ionicons name={p.icon} size={18} color={p.color} />
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
<View style={{ flex: 1 }}>
|
||||||
<Ionicons name="chevron-forward" size={14} color={colors.border} />
|
<Text
|
||||||
|
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t(p.labelKey)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={14} color={colors.border} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@ -295,253 +399,3 @@ function ProviderGrid({
|
|||||||
</ScrollView>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import { useState } from 'react';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMailConnect } from '../../hooks/useMailConnect';
|
import { useMailConnect } from '../../hooks/useMailConnect';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { humanizeMailError } from '../../lib/mailErrors';
|
import { humanizeMailError } from '../../lib/mailErrors';
|
||||||
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
import { FormSheet } from '../FormSheet';
|
||||||
|
import { SheetFieldStack } from '../SheetFieldStack';
|
||||||
const COLLAPSED_HEIGHT = 280;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -22,25 +21,22 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
|
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
|
||||||
const { connect, connecting, error: connectError } = useMailConnect();
|
const { connect, connecting, error: connectError } = useMailConnect();
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [fieldsComplete, setFieldsComplete] = useState(false);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setPasswordVisible(false);
|
setPasswordVisible(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
setFieldsComplete(false);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!password.trim()) {
|
|
||||||
setFormError(t('mail.form_fields_required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
const result = await connect({ email, password });
|
const result = await connect({ email, password });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
@ -51,89 +47,57 @@ 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 (
|
return (
|
||||||
<KeyboardAwareSheet
|
<FormSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
collapsedHeight={COLLAPSED_HEIGHT}
|
title={t('mail.edit_account_title')}
|
||||||
header={header}
|
initialHeightPct={0.5}
|
||||||
|
growWithKeyboard
|
||||||
>
|
>
|
||||||
<View style={{ padding: 20, gap: 14 }}>
|
<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}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={18}
|
||||||
|
color="#737373"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onComplete={() => setFieldsComplete(true)}
|
||||||
|
>
|
||||||
|
{/* Hint + Error + Save-Button */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: colors.textMuted,
|
color: '#6b7280',
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('mail.edit_account_subtitle', { email })}
|
{t('mail.edit_account_subtitle', { email })}
|
||||||
</Text>
|
</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>
|
|
||||||
|
|
||||||
{(formError ?? connectError) && (
|
{(formError ?? connectError) && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -143,6 +107,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="alert-circle" size={16} color="#dc2626" style={{ marginTop: 1 }} />
|
<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',
|
color: '#dc2626',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formError
|
{formError ?? t(humanizeMailError(connectError))}
|
||||||
? formError
|
|
||||||
: t(humanizeMailError(connectError))}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -164,14 +127,14 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={!password.trim() || connecting}
|
disabled={connecting}
|
||||||
style={{ marginTop: 4 }}
|
style={{ marginTop: 4, marginBottom: 12 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
|
backgroundColor: connecting ? '#bfdbfe' : '#007AFF',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -184,7 +147,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</SheetFieldStack>
|
||||||
</KeyboardAwareSheet>
|
</FormSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user