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,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
const OTP_LENGTH = 6;
|
const OTP_LENGTH = 6;
|
||||||
|
|
||||||
@ -111,11 +110,7 @@ export default function ConfirmOtpScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
<KeyboardAvoidingView
|
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<View className="flex-1 px-6 justify-center">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="items-center mb-8">
|
<View className="items-center mb-8">
|
||||||
<View className="w-16 h-16 bg-rebreak-500/10 rounded-full items-center justify-center mb-4">
|
<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>
|
<Text className="text-neutral-400 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToSignup')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</KeyboardAwareScreen>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,13 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
const INPUT_STYLE = {
|
const INPUT_STYLE = {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -47,11 +46,7 @@ export default function ForgotPasswordScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
<KeyboardAvoidingView
|
<KeyboardAwareScreen contentContainerStyle={{ paddingHorizontal: 24, justifyContent: 'center' }}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<View className="flex-1 px-6 justify-center">
|
|
||||||
<Text className="text-3xl text-neutral-900 mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.resetPasswordTitle')}</Text>
|
<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' }}>
|
<Text className="text-base text-neutral-500 mb-8 leading-6" style={{ fontFamily: 'Nunito_400Regular' }}>
|
||||||
{t('auth.resetPasswordSubtitle')}
|
{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>
|
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToLogin')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</KeyboardAwareScreen>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
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 Svg, { Path } from 'react-native-svg';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
function GoogleIcon() {
|
function GoogleIcon() {
|
||||||
return (
|
return (
|
||||||
@ -85,15 +83,10 @@ export default function SignInScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
<KeyboardAvoidingView
|
<KeyboardAwareScreen
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
scrollable
|
||||||
className="flex-1"
|
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-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' }}>
|
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
|
||||||
{t('auth.signinSubtitle')}
|
{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 className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signup')}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</KeyboardAwareScreen>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
Image,
|
Image,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@ -16,6 +13,7 @@ import Svg, { Path } from 'react-native-svg';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
|
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
|
||||||
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
function GoogleIcon() {
|
function GoogleIcon() {
|
||||||
return (
|
return (
|
||||||
@ -109,15 +107,10 @@ export default function SignUpScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
<KeyboardAvoidingView
|
<KeyboardAwareScreen
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
scrollable
|
||||||
className="flex-1"
|
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-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' }}>
|
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
|
||||||
{t('auth.signupSubtitle')}
|
{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 className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signin')}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</KeyboardAwareScreen>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,6 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Image,
|
Image,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
@ -24,6 +22,7 @@ import { resolveAvatar } from '../../lib/resolveAvatar';
|
|||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useMe } from '../../hooks/useMe';
|
import { useMe } from '../../hooks/useMe';
|
||||||
import { AvatarCropSheet } from '../../components/profile/AvatarCropSheet';
|
import { AvatarCropSheet } from '../../components/profile/AvatarCropSheet';
|
||||||
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
export default function ProfileEditScreen() {
|
export default function ProfileEditScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -133,10 +132,7 @@ export default function ProfileEditScreen() {
|
|||||||
avatarId !== me?.avatar;
|
avatarId !== me?.avatar;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAwareScreen style={{ backgroundColor: colors.bg }}>
|
||||||
style={{ flex: 1, backgroundColor: colors.bg }}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -322,6 +318,6 @@ export default function ProfileEditScreen() {
|
|||||||
onConfirm={handleCropConfirm}
|
onConfirm={handleCropConfirm}
|
||||||
onCancel={handleCropCancel}
|
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