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:
parent
7ec4be810b
commit
f24c364c81
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,15 +83,10 @@ export default function SignInScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
<KeyboardAwareScreen
|
||||
scrollable
|
||||
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', paddingHorizontal: 24 }}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
className="px-6"
|
||||
>
|
||||
<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' }}>
|
||||
{t('auth.signinSubtitle')}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,15 +107,10 @@ export default function SignUpScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white">
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
className="flex-1"
|
||||
<KeyboardAwareScreen
|
||||
scrollable
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 32, paddingHorizontal: 24, paddingTop: 32 }}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 32 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
className="px-6 pt-8"
|
||||
>
|
||||
<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' }}>
|
||||
{t('auth.signupSubtitle')}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
65
apps/rebreak-native/components/KeyboardAwareScreen.tsx
Normal file
65
apps/rebreak-native/components/KeyboardAwareScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user