feat(rebreak-native): KeyboardAwareScreen composable + full-screen form migration (phase 3A)

New component/KeyboardAwareScreen.tsx encapsulates the standard
KeyboardAvoidingView pattern for full-screen forms:
- iOS behavior="padding", Android no-op (adjustResize covers it)
- scrollable prop: ScrollView with keyboardShouldPersistTaps="handled"
- non-scrollable: TouchableWithoutFeedback+View for tap-to-dismiss
- headerOffset prop for screens owning their own header padding

Migrated to KeyboardAwareScreen: signin, signup, forgot-password,
confirm-otp (SafeAreaView-wrapped, no headerOffset needed) and
profile/edit (KAV wrapper only, explicit ScrollView retained).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-12 22:12:29 +02:00
parent 7ec4be810b
commit f24c364c81
6 changed files with 84 additions and 51 deletions

View File

@ -4,14 +4,13 @@ import {
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
const OTP_LENGTH = 6;
@ -111,11 +110,7 @@ export default function ConfirmOtpScreen() {
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<View className="flex-1 px-6 justify-center">
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
{/* Header */}
<View className="items-center mb-8">
<View className="w-16 h-16 bg-rebreak-500/10 rounded-full items-center justify-center mb-4">
@ -203,8 +198,7 @@ export default function ConfirmOtpScreen() {
>
<Text className="text-neutral-400 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToSignup')}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</KeyboardAwareScreen>
</SafeAreaView>
);
}

View File

@ -4,14 +4,13 @@ import {
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
const INPUT_STYLE = {
fontSize: 16,
@ -47,11 +46,7 @@ export default function ForgotPasswordScreen() {
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<View className="flex-1 px-6 justify-center">
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
<Text className="text-3xl text-neutral-900 mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.resetPasswordTitle')}</Text>
<Text className="text-base text-neutral-500 mb-8 leading-6" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.resetPasswordSubtitle')}
@ -108,8 +103,7 @@ export default function ForgotPasswordScreen() {
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToLogin')}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</KeyboardAwareScreen>
</SafeAreaView>
);
}

View File

@ -4,9 +4,6 @@ import {
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ScrollView,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
@ -14,6 +11,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
function GoogleIcon() {
return (
@ -85,14 +83,9 @@ export default function SignInScreen() {
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
keyboardShouldPersistTaps="handled"
className="px-6"
<KeyboardAwareScreen
scrollable
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', paddingHorizontal: 24 }}
>
<Text className="text-3xl text-neutral-900 mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.welcomeBack')}</Text>
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
@ -197,8 +190,7 @@ export default function SignInScreen() {
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signup')}</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
</KeyboardAwareScreen>
</SafeAreaView>
);
}

View File

@ -4,9 +4,6 @@ import {
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ScrollView,
Image,
ActivityIndicator,
} from 'react-native';
@ -16,6 +13,7 @@ import Svg, { Path } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
function GoogleIcon() {
return (
@ -109,14 +107,9 @@ export default function SignUpScreen() {
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled"
className="px-6 pt-8"
<KeyboardAwareScreen
scrollable
contentContainerStyle={{ flexGrow: 1, paddingBottom: 32, paddingHorizontal: 24, paddingTop: 32 }}
>
<Text className="text-3xl text-neutral-900 mb-1" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.signupTitle')}</Text>
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
@ -300,8 +293,7 @@ export default function SignUpScreen() {
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signin')}</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
</KeyboardAwareScreen>
</SafeAreaView>
);
}

View File

@ -7,8 +7,6 @@ import {
ScrollView,
Image,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { useRouter } from 'expo-router';
@ -24,6 +22,7 @@ import { resolveAvatar } from '../../lib/resolveAvatar';
import { apiFetch } from '../../lib/api';
import { useMe } from '../../hooks/useMe';
import { AvatarCropSheet } from '../../components/profile/AvatarCropSheet';
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
export default function ProfileEditScreen() {
const router = useRouter();
@ -133,10 +132,7 @@ export default function ProfileEditScreen() {
avatarId !== me?.avatar;
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<KeyboardAwareScreen style={{ backgroundColor: colors.bg }}>
<View
style={{
flexDirection: 'row',
@ -322,6 +318,6 @@ export default function ProfileEditScreen() {
onConfirm={handleCropConfirm}
onCancel={handleCropCancel}
/>
</KeyboardAvoidingView>
</KeyboardAwareScreen>
);
}

View File

@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleProp,
TouchableWithoutFeedback,
View,
ViewStyle,
} from 'react-native';
export interface KeyboardAwareScreenProps {
children: ReactNode;
/**
* Extra offset for `keyboardVerticalOffset` on iOS. For screens wrapped in
* `<SafeAreaView>` this should be 0 (default) the SafeAreaView already
* absorbs the top inset. For screens that own their header with
* `paddingTop: insets.top` baked in (e.g. profile/edit), pass the full
* header height so iOS computes the correct push distance.
*/
headerOffset?: number;
/**
* When true, wraps children in a `ScrollView`. Use for long forms (sign-up,
* profile-edit). When false (default), a plain `View` fills the remaining
* space tap anywhere outside an input dismisses the keyboard.
*/
scrollable?: boolean;
/** Style applied to the outer KeyboardAvoidingView. */
style?: StyleProp<ViewStyle>;
/** Style applied to the inner container (ScrollView or View). */
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function KeyboardAwareScreen({
children,
headerOffset = 0,
scrollable = false,
style,
contentContainerStyle,
}: KeyboardAwareScreenProps) {
return (
<KeyboardAvoidingView
style={[{ flex: 1 }, style]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerOffset}
>
{scrollable ? (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={contentContainerStyle}
keyboardShouldPersistTaps="handled"
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={[{ flex: 1 }, contentContainerStyle]}>{children}</View>
</TouchableWithoutFeedback>
)}
</KeyboardAvoidingView>
);
}