refactor(rebreak-native): migrate device sheets to FormSheet, delete KeyboardAwareSheet (phase 3C)

AddMacSheet + AddWindowsSheet now use FormSheet instead of the old
KeyboardAwareSheet. Steps with no TextInput disable growWithKeyboard;
Step 2 (long onboarding list) gets an internal ScrollView so content
is scrollable within the sheet cap. Sheet heights converted from fixed
px to initialHeightPct fractions.

KeyboardAwareSheet.tsx deleted — no remaining consumers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-12 22:12:35 +02:00
parent f24c364c81
commit 3eaf3f098a
3 changed files with 72 additions and 326 deletions

View File

@ -1,224 +0,0 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Easing,
Keyboard,
Modal,
Platform,
TouchableOpacity,
StyleProp,
View,
ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColors } from '../lib/theme';
/**
* Universal-Bottom-Sheet für Forms mit TextInput.
*
* Pattern (verifiziert auf PostCommentsSheet + EditMailAccountSheet):
*
* 1. Outer-Animated.View hat animated `height` (JS-driver) Sheet WÄCHST
* bei Tastatur-Open um genau die Tastatur-Höhe.
* 2. Inner-Animated.View hat `transform: translateY` (Native-driver)
* Slide-In/Out smooth. Driver-Mix-Trennung verhindert
* "Style property 'height' is not supported by native animated module"-Crash.
* 3. iOS: `paddingBottom: keyboardHeight` shifted Form innerhalb des
* gewachsenen Sheets über die Tastatur. Android: `windowSoftInputMode=adjustResize`
* im Manifest schrumpft das Window selbst.
* 4. Flex-Spacer drückt `children` (Form) automatisch an den Sheet-Bottom-Edge
* sitzt direkt über der Tastatur ohne Gap.
*
* Anti-Pattern (siehe `docs/internal/RECOVERY_LOG_2026-05-10.md` §7.2):
* - `useKeyboardAnimation()` aus `react-native-keyboard-controller` liefert
* in iOS-Modals keine Höhe (separate UIWindow). Hier: plain RN
* `Keyboard.addListener` für die Höhe.
* - `Animated.subtract`/`marginBottom: keyboardHeight` mischen JS+Native-Driver
* auf demselben View Bouncing oder Crash.
*
* Usage:
* ```tsx
* <KeyboardAwareSheet
* visible={visible}
* onClose={onClose}
* collapsedHeight={280}
* header={<HeaderRow title="Passwort" onCancel={onClose} />}
* >
* <View style={{ padding: 20, gap: 14 }}>
* <TextInput ... />
* <SaveButton ... />
* </View>
* </KeyboardAwareSheet>
* ```
*/
export interface KeyboardAwareSheetProps {
visible: boolean;
onClose: () => void;
/** Sheet-Höhe wenn Tastatur zu. Eng auf Inhalt zuschneiden — typisch 220-340px. */
collapsedHeight: number;
/** Optionaler Header (Cancel/Title-Row). Rendert direkt unter dem Drag-Handle. */
header?: ReactNode;
/** Form-Inhalt. Wird per Flex-Spacer an den Sheet-Bottom gedrückt sitzt
* damit direkt über der Tastatur sobald die offen ist. */
children: ReactNode;
/** Default true — Tap auf Backdrop schließt das Sheet. */
dismissOnBackdrop?: boolean;
/** Default true — kleiner Drag-Handle ganz oben am Sheet. */
showDragHandle?: boolean;
/** Default true — fügt unten eine Safe-Area-Spacer-Höhe ein (insets.bottom). */
showSafeAreaSpacer?: boolean;
/** Default true interner Flex-Spacer drückt children zum Sheet-Bottom.
* Auf false setzen wenn der Inhalt seine eigene Scroll-/Flex-Logik hat
* (z.B. ScrollView mit Provider-Grid, Listen). */
pushChildrenToBottom?: boolean;
/** Border-Radius oben. Default 20. */
topRadius?: number;
/** Optional zusätzlicher Style für den Sheet-Container. */
containerStyle?: StyleProp<ViewStyle>;
}
export function KeyboardAwareSheet({
visible,
onClose,
collapsedHeight,
header,
children,
dismissOnBackdrop = true,
showDragHandle = true,
showSafeAreaSpacer = true,
pushChildrenToBottom = true,
topRadius = 20,
containerStyle,
}: KeyboardAwareSheetProps) {
const colors = useColors();
const insets = useSafeAreaInsets();
const slideY = useRef(new Animated.Value(collapsedHeight)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const sheetHeight = useRef(new Animated.Value(collapsedHeight)).current;
const [keyboardHeight, setKeyboardHeight] = useState(0);
// Slide-In + Backdrop-Fade bei `visible=true`
useEffect(() => {
if (visible) {
slideY.setValue(collapsedHeight);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(slideY, {
toValue: 0,
duration: 280,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideY, backdropOpacity, collapsedHeight]);
// Sheet-Höhe wächst/schrumpft mit Tastatur
useEffect(() => {
const maxHeight = Dimensions.get('window').height - insets.top - 20;
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
const h = e.endCoordinates.height;
setKeyboardHeight(h);
Animated.timing(sheetHeight, {
toValue: Math.min(collapsedHeight + h, maxHeight),
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, (e) => {
setKeyboardHeight(0);
Animated.timing(sheetHeight, {
toValue: collapsedHeight,
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, [sheetHeight, collapsedHeight, insets.top]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
{/* Backdrop */}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
{dismissOnBackdrop && <TouchableOpacity activeOpacity={1} style={{ flex: 1 }} onPress={onClose} />}
</Animated.View>
{/* Outer: animated height (JS-driver) */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: sheetHeight,
}}
>
{/* Inner: animated transform (Native-driver). Driver-Mix vermeiden
durch zwei verschachtelte Animated.Views. */}
<Animated.View
style={[
{
flex: 1,
backgroundColor: colors.bg,
borderTopLeftRadius: topRadius,
borderTopRightRadius: topRadius,
transform: [{ translateY: slideY }],
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
},
containerStyle,
]}
>
<View style={{ flex: 1 }}>
{showDragHandle && (
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
}}
/>
</View>
)}
{header}
{pushChildrenToBottom ? (
<>
{/* Flex-Spacer drückt children an den Sheet-Bottom */}
<View style={{ flex: 1 }} />
{children}
</>
) : (
<View style={{ flex: 1 }}>{children}</View>
)}
{showSafeAreaSpacer && <View style={{ height: insets.bottom }} />}
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}

View File

@ -2,6 +2,7 @@ import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Linking, Linking,
ScrollView,
TouchableOpacity, TouchableOpacity,
Text, Text,
TextInput, TextInput,
@ -12,7 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar'; import { RiveAvatar } from '../RiveAvatar';
import { useProtectedDevicesStore } from '../../stores/protectedDevices'; import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@ -109,70 +110,52 @@ export function AddMacSheet({
router.push('/coach'); router.push('/coach');
} }
const collapsedHeight = step === 1 ? 300 : step === 2 ? 620 : 380; const sheetTitle =
step === 1
? t('devices.label_question')
: step === 2
? t('devices.download_button')
: t('devices.success_title');
const initialHeightPct = step === 1 ? 0.42 : step === 2 ? 0.74 : 0.52;
return ( return (
<KeyboardAwareSheet <FormSheet
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
collapsedHeight={collapsedHeight} title={sheetTitle}
pushChildrenToBottom={false} initialHeightPct={initialHeightPct}
header={ growWithKeyboard={step === 1}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 12,
}}
>
<Text
style={{
fontSize: 17,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{step === 1
? t('devices.label_question')
: step === 2
? t('devices.download_button')
: t('devices.success_title')}
</Text>
<TouchableOpacity
onPress={handleClose}
hitSlop={8}
activeOpacity={0.5}
>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
}
> >
{step === 1 && <Step1LabelContent {step === 1 && (
label={label} <Step1LabelContent
setLabel={setLabel} label={label}
labelError={labelError} setLabel={setLabel}
onPrepare={handlePrepare} labelError={labelError}
enrolling={enrolling} onPrepare={handlePrepare}
colors={colors} enrolling={enrolling}
t={t} colors={colors}
/>} t={t}
{step === 2 && <Step2OnboardingContent />
onDownload={handleDownload} )}
onConfirmInstalled={handleConfirmInstalled} {step === 2 && (
onNeedHelp={handleNeedHelp} <Step2OnboardingContent
confirming={confirming} onDownload={handleDownload}
colors={colors} onConfirmInstalled={handleConfirmInstalled}
t={t} onNeedHelp={handleNeedHelp}
/>} confirming={confirming}
{step === 3 && <Step3SuccessContent colors={colors}
onClose={handleClose} t={t}
colors={colors} />
t={t} )}
/>} {step === 3 && (
</KeyboardAwareSheet> <Step3SuccessContent
onClose={handleClose}
colors={colors}
t={t}
/>
)}
</FormSheet>
); );
} }
@ -261,7 +244,12 @@ function Step2OnboardingContent({
t: (k: string) => string; t: (k: string) => string;
}) { }) {
return ( return (
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}> <ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Lyra intro card */} {/* Lyra intro card */}
<View <View
style={{ style={{
@ -390,7 +378,7 @@ function Step2OnboardingContent({
{t('devices.need_help')} {t('devices.need_help')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </ScrollView>
); );
} }

View File

@ -2,6 +2,7 @@ import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Linking, Linking,
ScrollView,
TouchableOpacity, TouchableOpacity,
Text, Text,
TextInput, TextInput,
@ -12,7 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; import { FormSheet } from '../FormSheet';
import { RiveAvatar } from '../RiveAvatar'; import { RiveAvatar } from '../RiveAvatar';
import { useProtectedDevicesStore } from '../../stores/protectedDevices'; import { useProtectedDevicesStore } from '../../stores/protectedDevices';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@ -110,46 +111,22 @@ export function AddWindowsSheet({
router.push('/coach'); router.push('/coach');
} }
const collapsedHeight = step === 1 ? 300 : step === 2 ? 700 : 380; const sheetTitle =
step === 1
? t('devices.windows_label_question')
: step === 2
? t('devices.windows_download_button')
: t('devices.windows_success_title');
const initialHeightPct = step === 1 ? 0.42 : step === 2 ? 0.74 : 0.52;
return ( return (
<KeyboardAwareSheet <FormSheet
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
collapsedHeight={collapsedHeight} title={sheetTitle}
pushChildrenToBottom={false} initialHeightPct={initialHeightPct}
header={ growWithKeyboard={step === 1}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 12,
}}
>
<Text
style={{
fontSize: 17,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{step === 1
? t('devices.windows_label_question')
: step === 2
? t('devices.windows_download_button')
: t('devices.windows_success_title')}
</Text>
<TouchableOpacity
onPress={handleClose}
hitSlop={8}
activeOpacity={0.5}
>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
}
> >
{step === 1 && ( {step === 1 && (
<WindowsStep1LabelContent <WindowsStep1LabelContent
@ -179,7 +156,7 @@ export function AddWindowsSheet({
t={t} t={t}
/> />
)} )}
</KeyboardAwareSheet> </FormSheet>
); );
} }
@ -268,7 +245,12 @@ function WindowsStep2OnboardingContent({
t: (k: string) => string; t: (k: string) => string;
}) { }) {
return ( return (
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}> <ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Lyra intro card */} {/* Lyra intro card */}
<View <View
style={{ style={{
@ -397,7 +379,7 @@ function WindowsStep2OnboardingContent({
{t('devices.need_help')} {t('devices.need_help')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </ScrollView>
); );
} }