Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 112 additions & 116 deletions frontend/src/screens/LoginScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { StatusBar } from 'expo-status-bar';
import { useRef, useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Expand All @@ -13,139 +14,139 @@ import {

import { useAuthAccessForm } from '../features/auth/presentation/useAuthAccessForm';
import { colors, spacing } from '../shared/ui/theme';
import { useImeOverlap } from '../shared/ui/useImeOverlap';

type FocusedField = 'username' | 'password' | null;

export function LoginScreen() {
const passwordInputRef = useRef<TextInput>(null);
const { width: windowWidth } = useWindowDimensions();
const [focusedField, setFocusedField] = useState<FocusedField>(null);
const imeOverlap = useImeOverlap();
const { errors, isSubmitting, submit, submitError, updateField, values } = useAuthAccessForm();

return (
<View style={styles.screen}>
<View
style={[styles.imeLift, { transform: [{ translateY: imeOverlap ? -imeOverlap : 0 }] }]}
testID="login-ime-lift"
<KeyboardAvoidingView
Comment thread
gac0812 marked this conversation as resolved.
behavior={
Platform.OS === 'ios' ? 'padding' : Platform.OS === 'android' ? 'height' : undefined
}
Comment thread
gac0812 marked this conversation as resolved.
style={styles.screen}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
contentInsetAdjustmentBehavior="automatic"
// Android may scroll while revealing the focused field. Do not treat
// that native scroll as an explicit keyboard-dismiss gesture.
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'none'}
keyboardShouldPersistTaps="handled"
>
<ScrollView
contentContainerStyle={styles.scrollContent}
contentInsetAdjustmentBehavior="automatic"
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'on-drag'}
keyboardShouldPersistTaps="handled"
>
<View style={[styles.shell, { width: Math.min(windowWidth - spacing.xxl, 400) }]}>
<View style={styles.wordmark} testID="brand-region">
<Text style={styles.wordmarkTime}>Time</Text>
<Text style={styles.wordmarkFlow}>flow</Text>
</View>

<View style={styles.mainArea} testID="login-main">
<View style={styles.loginContent}>
<View style={styles.intro}>
<Text style={styles.title}>登录</Text>
<Text style={styles.subtitle}>首次使用会自动创建账号。</Text>
</View>

<View style={styles.formSurface}>
<View style={styles.fieldGroup}>
<Text style={styles.label}>用户名</Text>
<TextInput
accessibilityLabel="用户名"
autoCapitalize="none"
autoComplete="username"
autoCorrect={false}
editable={!isSubmitting}
enterKeyHint="next"
maxLength={128}
onBlur={() => setFocusedField(null)}
onChangeText={(value) => updateField('username', value)}
onFocus={() => setFocusedField('username')}
onSubmitEditing={() => passwordInputRef.current?.focus()}
placeholder="输入用户名"
placeholderTextColor={colors.mutedText}
selectionColor={colors.focus}
style={[
styles.input,
focusedField === 'username' && styles.inputFocused,
errors.username && styles.inputError,
]}
textContentType="username"
value={values.username}
/>
{errors.username ? (
<Text accessibilityLiveRegion="polite" style={styles.errorText}>
{errors.username}
</Text>
) : null}
</View>
<View style={[styles.shell, { width: Math.min(windowWidth - spacing.xxl, 400) }]}>
<View style={styles.wordmark} testID="brand-region">
<Text style={styles.wordmarkTime}>Time</Text>
<Text style={styles.wordmarkFlow}>flow</Text>
</View>

<View style={styles.fieldGroup}>
<Text style={styles.label}>密码</Text>
<TextInput
ref={passwordInputRef}
accessibilityLabel="密码"
autoCapitalize="none"
autoComplete="current-password"
editable={!isSubmitting}
enterKeyHint="done"
maxLength={128}
onBlur={() => setFocusedField(null)}
onChangeText={(value) => updateField('password', value)}
onFocus={() => setFocusedField('password')}
onSubmitEditing={submit}
placeholder="输入密码"
placeholderTextColor={colors.mutedText}
secureTextEntry
selectionColor={colors.focus}
style={[
styles.input,
focusedField === 'password' && styles.inputFocused,
errors.password && styles.inputError,
]}
textContentType="password"
value={values.password}
/>
{errors.password ? (
<Text accessibilityLiveRegion="polite" style={styles.errorText}>
{errors.password}
</Text>
) : null}
</View>
<View style={styles.mainArea} testID="login-main">
<View style={styles.loginContent}>
<View style={styles.intro}>
<Text style={styles.title}>登录</Text>
<Text style={styles.subtitle}>首次使用会自动创建账号。</Text>
</View>

{submitError ? (
<Text accessibilityLiveRegion="polite" style={styles.submitError}>
{submitError}
<View style={styles.formSurface}>
<View style={styles.fieldGroup}>
<Text style={styles.label}>用户名</Text>
<TextInput
accessibilityLabel="用户名"
autoCapitalize="none"
autoComplete="username"
autoCorrect={false}
editable={!isSubmitting}
enterKeyHint="next"
maxLength={128}
onBlur={() => setFocusedField(null)}
onChangeText={(value) => updateField('username', value)}
onFocus={() => setFocusedField('username')}
onSubmitEditing={() => passwordInputRef.current?.focus()}
placeholder="输入用户名"
placeholderTextColor={colors.mutedText}
selectionColor={colors.focus}
style={[
styles.input,
focusedField === 'username' && styles.inputFocused,
errors.username && styles.inputError,
]}
textContentType="username"
value={values.username}
/>
{errors.username ? (
<Text accessibilityLiveRegion="polite" style={styles.errorText}>
{errors.username}
</Text>
) : null}
</View>

<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: isSubmitting }}
disabled={isSubmitting}
onPress={submit}
style={({ pressed }) => [
styles.loginButton,
isSubmitting && styles.loginButtonDisabled,
pressed && !isSubmitting && styles.loginButtonPressed,
<View style={styles.fieldGroup}>
<Text style={styles.label}>密码</Text>
<TextInput
ref={passwordInputRef}
accessibilityLabel="密码"
autoCapitalize="none"
autoComplete="current-password"
editable={!isSubmitting}
enterKeyHint="done"
maxLength={128}
onBlur={() => setFocusedField(null)}
onChangeText={(value) => updateField('password', value)}
onFocus={() => setFocusedField('password')}
onSubmitEditing={submit}
placeholder="输入密码"
placeholderTextColor={colors.mutedText}
secureTextEntry
selectionColor={colors.focus}
style={[
styles.input,
focusedField === 'password' && styles.inputFocused,
errors.password && styles.inputError,
]}
>
<Text style={styles.loginButtonText}>{isSubmitting ? '提交中…' : '继续'}</Text>
</Pressable>
textContentType="password"
value={values.password}
/>
{errors.password ? (
<Text accessibilityLiveRegion="polite" style={styles.errorText}>
{errors.password}
</Text>
) : null}
</View>

{submitError ? (
<Text accessibilityLiveRegion="polite" style={styles.submitError}>
{submitError}
</Text>
) : null}

<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: isSubmitting }}
disabled={isSubmitting}
onPress={submit}
style={({ pressed }) => [
styles.loginButton,
isSubmitting && styles.loginButtonDisabled,
pressed && !isSubmitting && styles.loginButtonPressed,
]}
>
<Text style={styles.loginButtonText}>{isSubmitting ? '提交中…' : '继续'}</Text>
</Pressable>
</View>
</View>

<Text style={styles.privacyNote} testID="privacy-note">
你的日程只属于你
</Text>
</View>
</ScrollView>
</View>

<Text style={styles.privacyNote} testID="privacy-note">
你的日程只属于你
</Text>
</View>
</ScrollView>
<StatusBar style="dark" />
</View>
</KeyboardAvoidingView>
);
}

Expand Down Expand Up @@ -182,10 +183,6 @@ const styles = StyleSheet.create({
inputFocused: {
borderColor: colors.focus,
},
imeLift: {
flex: 1,
width: '100%',
},
intro: {
marginBottom: 28,
},
Expand Down Expand Up @@ -234,7 +231,6 @@ const styles = StyleSheet.create({
screen: {
backgroundColor: colors.background,
flex: 1,
overflow: 'hidden',
},
scrollContent: {
flexGrow: 1,
Expand Down
39 changes: 9 additions & 30 deletions frontend/tests/unit/screens/LoginScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react-native';
import { Dimensions, Image, Keyboard, StyleSheet, type KeyboardEvent } from 'react-native';
import { Image, KeyboardAvoidingView, ScrollView, StyleSheet } from 'react-native';

import { AuthAccessError, type AuthAccess } from '../../../src/contracts/auth';
import { AuthController } from '../../../src/features/auth/application';
Expand Down Expand Up @@ -68,24 +68,18 @@ describe('LoginScreen', () => {
expect(screen.getByLabelText('密码').props.secureTextEntry).toBe(true);
});

it('focuses fields, advances to the password, and lifts with the IME', () => {
const listeners = new Map<string, (event: KeyboardEvent) => void>();
jest.spyOn(Keyboard, 'addListener').mockImplementation((event, listener) => {
listeners.set(event, listener as (event: KeyboardEvent) => void);
return {
remove() {
listeners.delete(event);
},
} as ReturnType<typeof Keyboard.addListener>;
});
jest.spyOn(Keyboard, 'scheduleLayoutAnimation').mockImplementation(() => {});

it('keeps field focus transitions inside a native keyboard-avoiding layout', () => {
renderLogin(async () => ({
access_token: 'opaque-token',
account_id: 'acc_001',
expires_in: 3600,
}));

const keyboardContainer = screen.UNSAFE_getByType(KeyboardAvoidingView);
const scrollView = screen.UNSAFE_getByType(ScrollView);
expect(keyboardContainer.props.behavior).toBeTruthy();
expect(scrollView.props.keyboardDismissMode).not.toBe('on-drag');

const username = screen.getByLabelText('用户名');
const password = screen.getByLabelText('密码');
fireEvent(username, 'focus');
Expand All @@ -96,21 +90,6 @@ describe('LoginScreen', () => {
expect(StyleSheet.flatten(password.props.style)).toMatchObject({ borderColor: colors.focus });
fireEvent(password, 'blur');
fireEvent(screen.getByRole('button', { name: '继续' }), 'pressIn');

const windowHeight = Dimensions.get('window').height;
act(() => {
listeners.get('keyboardWillChangeFrame')?.({
duration: 0,
easing: 'keyboard',
endCoordinates: {
height: 300,
screenX: 0,
screenY: windowHeight - 300,
width: 400,
},
});
});
expect(screen.getByTestId('login-ime-lift')).toHaveStyle({ transform: [{ translateY: -300 }] });
});

it('disables fields and the submit control while authenticating', async () => {
Expand Down Expand Up @@ -139,14 +118,14 @@ describe('LoginScreen', () => {
});
});

it('keeps the login sheet unshifted until the IME covers the window', () => {
it('does not apply a parent transform when the login screen renders', () => {
renderLogin(async () => ({
access_token: 'opaque-token',
account_id: 'acc_001',
expires_in: 3600,
}));

expect(screen.getByTestId('login-ime-lift')).toHaveStyle({ transform: [{ translateY: 0 }] });
expect(screen.queryByTestId('login-ime-lift')).toBeNull();
});

it('validates empty fields without authenticating', async () => {
Expand Down
Loading