diff --git a/apps/lib/core/form_inputs/form_inputs.dart b/apps/lib/core/form_inputs/form_inputs.dart index 60d7600..25ff9b8 100644 --- a/apps/lib/core/form_inputs/form_inputs.dart +++ b/apps/lib/core/form_inputs/form_inputs.dart @@ -13,16 +13,17 @@ class Username extends FormzInput { } } -class Email extends FormzInput { - const Email.pure() : super.pure(''); - const Email.dirty([super.value = '']) : super.dirty(); +class Phone extends FormzInput { + const Phone.pure() : super.pure(''); + const Phone.dirty([super.value = '']) : super.dirty(); - static final _regex = RegExp(r'^[\w.-]+@[\w.-]+\.\w+$'); + static final _regex = RegExp(r'^\d{7,14}$'); @override String? validator(String value) { - if (value.isEmpty) return '请输入邮箱'; - if (!_regex.hasMatch(value)) return '邮箱格式不正确'; + final normalized = value.replaceAll(RegExp(r'\s+'), ''); + if (normalized.isEmpty) return '请输入手机号'; + if (!_regex.hasMatch(normalized)) return '手机号格式不正确'; return null; } } diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 5ca2a11..88fed0c 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -4,11 +4,8 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_state.dart'; import 'app_routes.dart'; import 'go_router_refresh_stream.dart'; -import '../../features/auth/ui/screens/login_screen.dart'; import '../../features/auth/ui/screens/auth_boot_screen.dart'; -import '../../features/auth/ui/screens/register_screen.dart'; -import '../../features/auth/ui/screens/register_verification_screen.dart'; -import '../../features/auth/ui/screens/reset_password_screen.dart'; +import '../../features/auth/ui/screens/login_screen.dart'; import '../../features/home/ui/screens/home_screen.dart'; import '../../features/messages/ui/screens/message_invite_list_screen.dart'; import '../../features/messages/ui/screens/message_invite_detail_screen.dart'; @@ -28,7 +25,6 @@ import '../../features/settings/ui/screens/settings_screen.dart'; import '../../features/settings/ui/screens/features_screen.dart'; import '../../features/settings/ui/screens/memory_screen.dart'; import '../../features/settings/ui/screens/account_screen.dart'; -import '../../features/settings/ui/screens/change_password_screen.dart'; import '../../features/settings/ui/screens/edit_profile_screen.dart'; final _protectedRoutes = [ @@ -43,7 +39,6 @@ final _protectedRoutes = [ AppRoutes.settingsFeatures, AppRoutes.settingsMemory, AppRoutes.settingsAccount, - AppRoutes.settingsChangePassword, AppRoutes.settingsEditProfile, AppRoutes.messageInviteList, ]; @@ -61,8 +56,7 @@ GoRouter createAppRouter(AuthBloc authBloc) { final isBootRoute = state.matchedLocation == AppRoutes.authBoot; final isAuthRoute = state.matchedLocation == AppRoutes.authLogin || - state.matchedLocation.startsWith('/login') || - state.matchedLocation.startsWith('/register'); + state.matchedLocation.startsWith('/login'); final isProtected = _protectedRoutes.any( (route) => state.matchedLocation.startsWith(route), ); @@ -86,10 +80,6 @@ GoRouter createAppRouter(AuthBloc authBloc) { path: AppRoutes.authBoot, builder: (context, state) => const AuthBootScreen(), ), - GoRoute( - path: AppRoutes.authLogin, - builder: (context, state) => const LoginScreen(), - ), GoRoute( path: AppRoutes.calendarEventCreate, builder: (context, state) => CalendarEventCreateScreen( @@ -112,16 +102,8 @@ GoRouter createAppRouter(AuthBloc authBloc) { CalendarEventShareScreen(eventId: state.pathParameters['id']!), ), GoRoute( - path: AppRoutes.authRegister, - builder: (context, state) => const RegisterScreen(), - ), - GoRoute( - path: AppRoutes.authRegisterVerification, - builder: (context, state) => const RegisterVerificationScreen(), - ), - GoRoute( - path: AppRoutes.authResetPassword, - builder: (context, state) => const ResetPasswordScreen(), + path: AppRoutes.authLogin, + builder: (context, state) => const LoginScreen(), ), GoRoute( path: AppRoutes.homeMain, @@ -196,10 +178,6 @@ GoRouter createAppRouter(AuthBloc authBloc) { path: AppRoutes.settingsAccount, builder: (context, state) => const AccountScreen(), ), - GoRoute( - path: AppRoutes.settingsChangePassword, - builder: (context, state) => const ChangePasswordScreen(), - ), GoRoute( path: AppRoutes.settingsEditProfile, builder: (context, state) => const EditProfileScreen(), diff --git a/apps/lib/core/router/app_routes.dart b/apps/lib/core/router/app_routes.dart index 0177892..9585b7e 100644 --- a/apps/lib/core/router/app_routes.dart +++ b/apps/lib/core/router/app_routes.dart @@ -3,9 +3,6 @@ class AppRoutes { static const authBoot = '/boot'; static const authLogin = '/'; - static const authRegister = '/register'; - static const authRegisterVerification = '/register/verification'; - static const authResetPassword = '/reset-password'; static const homeMain = '/home'; @@ -31,6 +28,5 @@ class AppRoutes { static const settingsFeatures = '/settings/features'; static const settingsMemory = '/settings/memory'; static const settingsAccount = '/settings/account'; - static const settingsChangePassword = '/change-password'; static const settingsEditProfile = '/edit-profile'; } diff --git a/apps/lib/features/auth/data/auth_api.dart b/apps/lib/features/auth/data/auth_api.dart index 201ceb6..1eb4522 100644 --- a/apps/lib/features/auth/data/auth_api.dart +++ b/apps/lib/features/auth/data/auth_api.dart @@ -9,34 +9,13 @@ class AuthApi { AuthApi(this._client); - Future createVerification( - SignupStartRequest request, - ) async { - final response = await _client.post( - '$_prefix/verifications', - data: request.toJson(), - ); - return VerificationCreateResponse.fromJson(response.data); + Future sendOtp(OtpSendRequest request) async { + await _client.post('$_prefix/otp/send', data: request.toJson()); } - Future verifyVerification(SignupVerifyRequest request) async { + Future createPhoneSession(LoginRequest request) async { final response = await _client.post( - '$_prefix/verify', - data: {'type': 'signup', ...request.toJson()}, - ); - return AuthResponse.fromJson(response.data); - } - - Future resendVerification(SignupResendRequest request) async { - await _client.post( - '$_prefix/resend', - data: {'type': 'signup', ...request.toJson()}, - ); - } - - Future createSession(LoginRequest request) async { - final response = await _client.post( - '$_prefix/sessions', + '$_prefix/phone-session', data: request.toJson(), ); return AuthResponse.fromJson(response.data); @@ -53,27 +32,4 @@ class AuthApi { Future deleteSession(LogoutRequest request) async { await _client.delete('$_prefix/sessions', data: request.toJson()); } - - Future requestPasswordReset(String email) async { - await _client.post( - '$_prefix/resend', - data: {'type': 'recovery', 'email': email}, - ); - } - - Future confirmPasswordReset({ - required String email, - required String token, - required String newPassword, - }) async { - await _client.post( - '$_prefix/verify', - data: { - 'type': 'recovery', - 'email': email, - 'token': token, - 'new_password': newPassword, - }, - ); - } } diff --git a/apps/lib/features/auth/data/auth_repository.dart b/apps/lib/features/auth/data/auth_repository.dart index 4d0ccbc..b339e28 100644 --- a/apps/lib/features/auth/data/auth_repository.dart +++ b/apps/lib/features/auth/data/auth_repository.dart @@ -1,24 +1,15 @@ -import 'package:social_app/features/auth/data/models/signup_request.dart'; -import 'package:social_app/features/auth/data/models/login_request.dart'; import 'package:social_app/features/auth/data/models/auth_response.dart'; abstract class AuthRepository { - Future createVerification( - SignupStartRequest request, - ); - Future verifyVerification(SignupVerifyRequest request); - Future resendVerification(SignupResendRequest request); - Future createSession(LoginRequest request); + Future sendOtp(String phone); + Future createPhoneSession({ + required String phone, + required String token, + }); Future refreshSession(String refreshToken); Future deleteSession(); Future clearSessionLocalOnly(); Future getAccessToken(); Future getRefreshToken(); Future isAuthenticated(); - Future requestPasswordReset(String email); - Future confirmPasswordReset({ - required String email, - required String token, - required String newPassword, - }); } diff --git a/apps/lib/features/auth/data/auth_repository_impl.dart b/apps/lib/features/auth/data/auth_repository_impl.dart index f1c19e1..4945054 100644 --- a/apps/lib/features/auth/data/auth_repository_impl.dart +++ b/apps/lib/features/auth/data/auth_repository_impl.dart @@ -19,30 +19,18 @@ class AuthRepositoryImpl implements AuthRepository { _onLogout = onLogout; @override - Future createVerification( - SignupStartRequest request, - ) { - return _api.createVerification(request); + Future sendOtp(String phone) { + return _api.sendOtp(OtpSendRequest(phone: phone)); } @override - Future verifyVerification(SignupVerifyRequest request) async { - final response = await _api.verifyVerification(request); - await _tokenStorage.saveTokens( - access: response.accessToken, - refresh: response.refreshToken, + Future createPhoneSession({ + required String phone, + required String token, + }) async { + final response = await _api.createPhoneSession( + LoginRequest(phone: phone, token: token), ); - return response; - } - - @override - Future resendVerification(SignupResendRequest request) { - return _api.resendVerification(request); - } - - @override - Future createSession(LoginRequest request) async { - final response = await _api.createSession(request); await _tokenStorage.saveTokens( access: response.accessToken, refresh: response.refreshToken, @@ -94,22 +82,4 @@ class AuthRepositoryImpl implements AuthRepository { final token = await _tokenStorage.getAccessToken(); return token != null; } - - @override - Future requestPasswordReset(String email) { - return _api.requestPasswordReset(email); - } - - @override - Future confirmPasswordReset({ - required String email, - required String token, - required String newPassword, - }) { - return _api.confirmPasswordReset( - email: email, - token: token, - newPassword: newPassword, - ); - } } diff --git a/apps/lib/features/auth/data/models/auth_response.dart b/apps/lib/features/auth/data/models/auth_response.dart index db1bc70..56f0390 100644 --- a/apps/lib/features/auth/data/models/auth_response.dart +++ b/apps/lib/features/auth/data/models/auth_response.dart @@ -1,11 +1,11 @@ class AuthUser { final String id; - final String email; + final String phone; - const AuthUser({required this.id, required this.email}); + const AuthUser({required this.id, required this.phone}); factory AuthUser.fromJson(Map json) { - return AuthUser(id: json['id'] as String, email: json['email'] as String); + return AuthUser(id: json['id'] as String, phone: json['phone'] as String); } } @@ -34,13 +34,3 @@ class AuthResponse { ); } } - -class VerificationCreateResponse { - final String email; - - const VerificationCreateResponse({required this.email}); - - factory VerificationCreateResponse.fromJson(Map json) { - return VerificationCreateResponse(email: json['email'] as String); - } -} diff --git a/apps/lib/features/auth/data/models/login_request.dart b/apps/lib/features/auth/data/models/login_request.dart index 8d772a2..b8eafbc 100644 --- a/apps/lib/features/auth/data/models/login_request.dart +++ b/apps/lib/features/auth/data/models/login_request.dart @@ -1,10 +1,22 @@ class LoginRequest { - final String email; - final String password; + final String phone; + final String token; - const LoginRequest({required this.email, required this.password}); + const LoginRequest({required this.phone, required this.token}); - Map toJson() => {'email': email, 'password': password}; + Map toJson() => { + 'phone': _normalizePhone(phone), + 'token': token, + }; +} + +String _normalizePhone(String input) { + var normalized = input.trim(); + normalized = normalized.replaceAll(RegExp(r'[\s\-\(\)]'), ''); + if (normalized.startsWith('00') && normalized.length > 2) { + return '+${normalized.substring(2)}'; + } + return normalized; } class RefreshRequest { diff --git a/apps/lib/features/auth/data/models/signup_request.dart b/apps/lib/features/auth/data/models/signup_request.dart index c04ee7b..bd920d1 100644 --- a/apps/lib/features/auth/data/models/signup_request.dart +++ b/apps/lib/features/auth/data/models/signup_request.dart @@ -1,37 +1,28 @@ -class SignupStartRequest { - final String username; - final String email; - final String password; - final String? inviteCode; +class OtpSendRequest { + final String phone; - const SignupStartRequest({ - required this.username, - required this.email, - required this.password, - this.inviteCode, - }); + const OtpSendRequest({required this.phone}); + + Map toJson() => {'phone': _normalizePhone(phone)}; +} + +class PhoneSessionRequest { + final String phone; + final String token; + + const PhoneSessionRequest({required this.phone, required this.token}); Map toJson() => { - 'username': username, - 'email': email, - 'password': password, - if (inviteCode != null) 'invite_code': inviteCode, + 'phone': _normalizePhone(phone), + 'token': token, }; } -class SignupVerifyRequest { - final String email; - final String token; - - const SignupVerifyRequest({required this.email, required this.token}); - - Map toJson() => {'email': email, 'token': token}; -} - -class SignupResendRequest { - final String email; - - const SignupResendRequest({required this.email}); - - Map toJson() => {'email': email}; +String _normalizePhone(String input) { + var normalized = input.trim(); + normalized = normalized.replaceAll(RegExp(r'[\s\-\(\)]'), ''); + if (normalized.startsWith('00') && normalized.length > 2) { + return '+${normalized.substring(2)}'; + } + return normalized; } diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index eb5391b..ca0f232 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -21,7 +21,7 @@ class AuthBloc extends Bloc { final response = await _repository.refreshSession(refreshToken); emit( AuthAuthenticated( - user: AuthUser(id: response.user.id, email: response.user.email), + user: AuthUser(id: response.user.id, phone: response.user.phone), ), ); return; diff --git a/apps/lib/features/auth/presentation/cubits/login_cubit.dart b/apps/lib/features/auth/presentation/cubits/login_cubit.dart index b493af8..839cdac 100644 --- a/apps/lib/features/auth/presentation/cubits/login_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/login_cubit.dart @@ -1,56 +1,161 @@ +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:equatable/equatable.dart'; import '../../../../core/api/api_exception.dart'; import '../../data/auth_repository.dart'; -import '../../data/models/login_request.dart'; import '../../data/models/auth_response.dart'; import '../../../../core/form_inputs/form_inputs.dart'; class LoginState extends Equatable { - final Email email; - final Password password; + static const defaultDialCode = '+86'; + static final _e164Regex = RegExp(r'^\+[1-9]\d{7,14}$'); + + final String dialCode; + final Phone phone; + final VerificationCode code; + final bool codeSent; + final bool isSendingCode; + final int resendCooldownSeconds; final FormzSubmissionStatus status; final String? errorMessage; const LoginState({ - this.email = const Email.pure(), - this.password = const Password.pure(), + this.dialCode = defaultDialCode, + this.phone = const Phone.pure(), + this.code = const VerificationCode.pure(), + this.codeSent = false, + this.isSendingCode = false, + this.resendCooldownSeconds = 0, this.status = FormzSubmissionStatus.initial, this.errorMessage, }); - bool get isValid => email.isValid && password.isValid; + bool get isPhoneValidForApi => + phone.isValid && _e164Regex.hasMatch(e164Phone); + bool get isValid => isPhoneValidForApi && code.isValid; + String get e164Phone => '$dialCode${phone.value}'; + bool get canSendCode => + isPhoneValidForApi && !isSendingCode && resendCooldownSeconds == 0; LoginState copyWith({ - Email? email, - Password? password, + String? dialCode, + Phone? phone, + VerificationCode? code, + bool? codeSent, + bool? isSendingCode, + int? resendCooldownSeconds, FormzSubmissionStatus? status, String? errorMessage, }) { return LoginState( - email: email ?? this.email, - password: password ?? this.password, + dialCode: dialCode ?? this.dialCode, + phone: phone ?? this.phone, + code: code ?? this.code, + codeSent: codeSent ?? this.codeSent, + isSendingCode: isSendingCode ?? this.isSendingCode, + resendCooldownSeconds: + resendCooldownSeconds ?? this.resendCooldownSeconds, status: status ?? this.status, errorMessage: errorMessage, ); } @override - List get props => [email, password, status, errorMessage]; + List get props => [ + dialCode, + phone, + code, + codeSent, + isSendingCode, + resendCooldownSeconds, + status, + errorMessage, + ]; } class LoginCubit extends Cubit { final AuthRepository _repository; + Timer? _resendTimer; LoginCubit(this._repository) : super(const LoginState()); - void emailChanged(String value) { - emit(state.copyWith(email: Email.dirty(value))); + void phoneChanged(String value) { + final nextPhone = Phone.dirty(value); + if (nextPhone.value == state.phone.value) { + emit(state.copyWith(phone: nextPhone, errorMessage: null)); + return; + } + _resendTimer?.cancel(); + emit( + state.copyWith( + phone: nextPhone, + code: const VerificationCode.pure(), + codeSent: false, + resendCooldownSeconds: 0, + errorMessage: null, + ), + ); } - void passwordChanged(String value) { - emit(state.copyWith(password: Password.dirty(value))); + void dialCodeChanged(String value) { + if (value == state.dialCode) { + return; + } + _resendTimer?.cancel(); + emit( + state.copyWith( + dialCode: value, + code: const VerificationCode.pure(), + codeSent: false, + resendCooldownSeconds: 0, + errorMessage: null, + ), + ); + } + + void codeChanged(String value) { + emit(state.copyWith(code: VerificationCode.dirty(value))); + } + + Future sendCode() async { + if (!state.phone.isValid) { + emit(state.copyWith(errorMessage: '请输入有效手机号')); + return false; + } + if (!state.canSendCode) { + return false; + } + + final requestPhone = state.e164Phone; + emit(state.copyWith(isSendingCode: true, errorMessage: null)); + try { + await _repository.sendOtp(requestPhone); + if (isClosed) { + return false; + } + if (state.e164Phone != requestPhone) { + emit(state.copyWith(isSendingCode: false)); + return false; + } + emit( + state.copyWith( + isSendingCode: false, + codeSent: true, + errorMessage: null, + ), + ); + _startResendCooldown(); + return true; + } catch (e) { + if (isClosed) { + return false; + } + final message = e is ApiException ? e.message : '验证码发送失败'; + emit(state.copyWith(isSendingCode: false, errorMessage: message)); + return false; + } } Future submit() async { @@ -59,12 +164,19 @@ class LoginCubit extends Cubit { emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); try { - final response = await _repository.createSession( - LoginRequest(email: state.email.value, password: state.password.value), + final response = await _repository.createPhoneSession( + phone: state.e164Phone, + token: state.code.value, ); + if (isClosed) { + return null; + } emit(state.copyWith(status: FormzSubmissionStatus.success)); return response; } catch (e) { + if (isClosed) { + return null; + } final message = e is ApiException ? e.message : e.toString(); emit( state.copyWith( @@ -75,4 +187,29 @@ class LoginCubit extends Cubit { return null; } } + + void _startResendCooldown() { + _resendTimer?.cancel(); + emit(state.copyWith(resendCooldownSeconds: 60)); + _resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (isClosed) { + timer.cancel(); + return; + } + if (state.resendCooldownSeconds <= 1) { + timer.cancel(); + emit(state.copyWith(resendCooldownSeconds: 0)); + return; + } + emit( + state.copyWith(resendCooldownSeconds: state.resendCooldownSeconds - 1), + ); + }); + } + + @override + Future close() { + _resendTimer?.cancel(); + return super.close(); + } } diff --git a/apps/lib/features/auth/presentation/cubits/register_cubit.dart b/apps/lib/features/auth/presentation/cubits/register_cubit.dart deleted file mode 100644 index 4fab03c..0000000 --- a/apps/lib/features/auth/presentation/cubits/register_cubit.dart +++ /dev/null @@ -1,238 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:formz/formz.dart'; -import 'package:equatable/equatable.dart'; -import '../../../../core/api/api_exception.dart'; -import '../../../../core/form_inputs/form_inputs.dart'; -import '../../data/auth_repository.dart'; -import '../../data/models/signup_request.dart'; -import '../../data/models/auth_response.dart'; - -class RegisterState extends Equatable { - final Username username; - final Email email; - final Password password; - final VerificationCode verificationCode; - final String inviteCode; - final FormzSubmissionStatus status; - final String? errorMessage; - final String? pendingEmail; - final bool codeSent; - final bool isSending; - - const RegisterState({ - this.username = const Username.pure(), - this.email = const Email.pure(), - this.password = const Password.pure(), - this.verificationCode = const VerificationCode.pure(), - this.inviteCode = '', - this.status = FormzSubmissionStatus.initial, - this.errorMessage, - this.pendingEmail, - this.codeSent = false, - this.isSending = false, - }); - - bool get isStep1Valid => - username.isValid && email.isValid && password.isValid; - bool get isStep2Valid => verificationCode.isValid; - - RegisterState copyWith({ - Username? username, - Email? email, - Password? password, - VerificationCode? verificationCode, - String? inviteCode, - FormzSubmissionStatus? status, - String? errorMessage, - String? pendingEmail, - bool? codeSent, - bool? isSending, - }) { - return RegisterState( - username: username ?? this.username, - email: email ?? this.email, - password: password ?? this.password, - verificationCode: verificationCode ?? this.verificationCode, - inviteCode: inviteCode ?? this.inviteCode, - status: status ?? this.status, - errorMessage: errorMessage, - pendingEmail: pendingEmail ?? this.pendingEmail, - codeSent: codeSent ?? this.codeSent, - isSending: isSending ?? this.isSending, - ); - } - - @override - List get props => [ - username, - email, - password, - verificationCode, - inviteCode, - status, - errorMessage, - pendingEmail, - codeSent, - isSending, - ]; -} - -class RegisterCubit extends Cubit { - final AuthRepository _repository; - - RegisterCubit(this._repository) : super(const RegisterState()); - - void usernameChanged(String value) { - emit(state.copyWith(username: Username.dirty(value))); - } - - void emailChanged(String value) { - emit(state.copyWith(email: Email.dirty(value))); - } - - void passwordChanged(String value) { - emit(state.copyWith(password: Password.dirty(value))); - } - - void verificationCodeChanged(String value) { - emit(state.copyWith(verificationCode: VerificationCode.dirty(value))); - } - - void inviteCodeChanged(String value) { - emit(state.copyWith(inviteCode: value)); - } - - Future submitStep1() async { - if (!state.isStep1Valid) return false; - - emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); - - try { - final response = await _repository.createVerification( - SignupStartRequest( - username: state.username.value, - email: state.email.value, - password: state.password.value, - inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null, - ), - ); - emit( - state.copyWith( - status: FormzSubmissionStatus.success, - pendingEmail: response.email, - codeSent: true, - ), - ); - return true; - } catch (e) { - final message = e is ApiException ? e.message : e.toString(); - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: message, - ), - ); - return false; - } - } - - Future submitStep2() async { - if (!state.isStep2Valid || state.pendingEmail == null) return null; - - emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); - - try { - final response = await _repository.verifyVerification( - SignupVerifyRequest( - email: state.pendingEmail!, - token: state.verificationCode.value, - ), - ); - emit(state.copyWith(status: FormzSubmissionStatus.success)); - return response; - } catch (e) { - final message = e is ApiException ? e.message : e.toString(); - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: message, - ), - ); - return null; - } - } - - Future resendCode() async { - if (state.pendingEmail == null) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '验证码发送失败,请返回上一步重试', - ), - ); - return false; - } - - emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); - - try { - await _repository.resendVerification( - SignupResendRequest(email: state.pendingEmail!), - ); - emit( - state.copyWith(status: FormzSubmissionStatus.success, codeSent: true), - ); - return true; - } catch (e) { - final message = e is ApiException ? e.message : '验证码发送失败,请重试'; - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: message, - ), - ); - return false; - } - } - - Future sendCodeSilently() async { - if (!state.isStep1Valid || state.isSending) return; - - emit( - state.copyWith( - isSending: true, - status: FormzSubmissionStatus.inProgress, - errorMessage: null, - ), - ); - - try { - final response = await _repository.createVerification( - SignupStartRequest( - username: state.username.value, - email: state.email.value, - password: state.password.value, - inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null, - ), - ); - emit( - state.copyWith( - isSending: false, - status: FormzSubmissionStatus.success, - pendingEmail: response.email, - codeSent: true, - errorMessage: null, - ), - ); - } catch (e) { - final message = e is ApiException ? e.message : '验证码发送失败,请重试'; - emit( - state.copyWith( - isSending: false, - status: FormzSubmissionStatus.failure, - errorMessage: message, - ), - ); - } - } -} diff --git a/apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart b/apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart deleted file mode 100644 index 2a74a32..0000000 --- a/apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'dart:async'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:formz/formz.dart'; -import '../../../../core/form_inputs/form_inputs.dart'; -import '../../data/auth_repository.dart'; - -class ResetPasswordState extends Equatable { - final Email email; - final VerificationCode code; - final Password newPassword; - final Password confirmPassword; - final FormzSubmissionStatus status; - final String? errorMessage; - final bool isSuccess; - final int resendCountdown; - final bool codeSent; - - const ResetPasswordState({ - this.email = const Email.pure(), - this.code = const VerificationCode.pure(), - this.newPassword = const Password.pure(), - this.confirmPassword = const Password.pure(), - this.status = FormzSubmissionStatus.initial, - this.errorMessage, - this.isSuccess = false, - this.resendCountdown = 0, - this.codeSent = false, - }); - - bool get canSubmit { - if (!codeSent) { - return email.isValid && status != FormzSubmissionStatus.inProgress; - } - return email.isValid && - code.isValid && - newPassword.isValid && - confirmPassword.isValid && - newPassword.value == confirmPassword.value && - status != FormzSubmissionStatus.inProgress; - } - - ResetPasswordState copyWith({ - Email? email, - VerificationCode? code, - Password? newPassword, - Password? confirmPassword, - FormzSubmissionStatus? status, - String? errorMessage, - bool? isSuccess, - int? resendCountdown, - bool? codeSent, - }) { - return ResetPasswordState( - email: email ?? this.email, - code: code ?? this.code, - newPassword: newPassword ?? this.newPassword, - confirmPassword: confirmPassword ?? this.confirmPassword, - status: status ?? this.status, - errorMessage: errorMessage, - isSuccess: isSuccess ?? this.isSuccess, - resendCountdown: resendCountdown ?? this.resendCountdown, - codeSent: codeSent ?? this.codeSent, - ); - } - - @override - List get props => [ - email, - code, - newPassword, - confirmPassword, - status, - errorMessage, - isSuccess, - resendCountdown, - codeSent, - ]; -} - -class ResetPasswordCubit extends Cubit { - final AuthRepository _repository; - Timer? _resendTimer; - - ResetPasswordCubit(this._repository) : super(const ResetPasswordState()); - - @override - Future close() { - _resendTimer?.cancel(); - return super.close(); - } - - void emailChanged(String value) { - emit(state.copyWith(email: Email.dirty(value), errorMessage: null)); - } - - void codeChanged(String value) { - emit( - state.copyWith(code: VerificationCode.dirty(value), errorMessage: null), - ); - } - - void newPasswordChanged(String value) { - emit( - state.copyWith(newPassword: Password.dirty(value), errorMessage: null), - ); - } - - void confirmPasswordChanged(String value) { - emit( - state.copyWith( - confirmPassword: Password.dirty(value), - errorMessage: null, - ), - ); - } - - Future sendCode() async { - if (state.status == FormzSubmissionStatus.inProgress || - state.resendCountdown > 0) { - return; - } - - if (!state.email.isValid) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确', - ), - ); - return; - } - - emit( - state.copyWith( - status: FormzSubmissionStatus.inProgress, - codeSent: true, - resendCountdown: 60, - errorMessage: null, - ), - ); - _startResendCountdown(); - - try { - await _repository.requestPasswordReset(state.email.value); - emit( - state.copyWith( - status: FormzSubmissionStatus.success, - errorMessage: 'CODE_SENT_SUCCESS', - ), - ); - } catch (e) { - _cancelResendCountdown(); - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - codeSent: false, - resendCountdown: 0, - errorMessage: '网络错误,请稍后重试', - ), - ); - } - } - - void _cancelResendCountdown() { - _resendTimer?.cancel(); - } - - void _startResendCountdown() { - _cancelResendCountdown(); - _resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - final newCountdown = state.resendCountdown - 1; - if (newCountdown <= 0) { - timer.cancel(); - emit(state.copyWith(resendCountdown: 0)); - } else { - emit(state.copyWith(resendCountdown: newCountdown)); - } - }); - } - - Future resendCode() async { - if (state.resendCountdown > 0 || - state.status == FormzSubmissionStatus.inProgress) { - return; - } - - if (!state.email.isValid) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确', - ), - ); - return; - } - - emit( - state.copyWith( - status: FormzSubmissionStatus.inProgress, - codeSent: true, - resendCountdown: 60, - errorMessage: null, - ), - ); - _startResendCountdown(); - - try { - await _repository.requestPasswordReset(state.email.value); - emit( - state.copyWith( - status: FormzSubmissionStatus.success, - errorMessage: 'CODE_SENT_SUCCESS', - ), - ); - } catch (e) { - _cancelResendCountdown(); - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - resendCountdown: 0, - errorMessage: '网络错误,请稍后重试', - ), - ); - } - } - - Future submit() async { - if (!state.codeSent) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '请先获取验证码', - ), - ); - return; - } - - if (!state.email.isValid) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '请输入有效的邮箱地址', - ), - ); - return; - } - - if (!state.code.isValid) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '请输入6位验证码', - ), - ); - return; - } - - if (!state.newPassword.isValid) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '新密码至少6位', - ), - ); - return; - } - - if (!state.confirmPassword.isValid) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '请输入确认密码', - ), - ); - return; - } - - if (state.newPassword.value != state.confirmPassword.value) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '两次密码输入不一致', - ), - ); - return; - } - - emit( - state.copyWith( - status: FormzSubmissionStatus.inProgress, - errorMessage: null, - ), - ); - - try { - await _repository.confirmPasswordReset( - email: state.email.value, - token: state.code.value, - newPassword: state.newPassword.value, - ); - emit( - state.copyWith(status: FormzSubmissionStatus.success, isSuccess: true), - ); - } catch (e) { - emit( - state.copyWith( - status: FormzSubmissionStatus.failure, - errorMessage: '密码重置失败,请检查验证码', - ), - ); - } - } -} diff --git a/apps/lib/features/auth/ui/screens/login_screen.dart b/apps/lib/features/auth/ui/screens/login_screen.dart index 584e4d9..8a7bf76 100644 --- a/apps/lib/features/auth/ui/screens/login_screen.dart +++ b/apps/lib/features/auth/ui/screens/login_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:go_router/go_router.dart'; @@ -8,7 +9,9 @@ import '../../../../core/router/app_routes.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/banner/app_banner.dart'; +import '../../../../shared/widgets/confirm_sheet.dart'; import '../../../../shared/widgets/link_button.dart'; +import '../../../../shared/widgets/phone_prefix_selector.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/auth_repository.dart'; import '../../presentation/bloc/auth_bloc.dart'; @@ -16,7 +19,6 @@ import '../../presentation/bloc/auth_event.dart'; import '../../presentation/cubits/login_cubit.dart'; import '../widgets/auth_field.dart'; import '../widgets/auth_page_scaffold.dart'; -import '../widgets/password_field.dart'; class LoginScreen extends StatelessWidget { const LoginScreen({super.key}); @@ -38,20 +40,23 @@ class LoginView extends StatefulWidget { } class _LoginViewState extends State { - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); + static const _dialCodes = ['+86', '+1', '+44', '+81', '+65']; + + final _phoneController = TextEditingController(); + final _codeController = TextEditingController(); + bool _agreedToTerms = false; @override void dispose() { - _emailController.dispose(); - _passwordController.dispose(); + _phoneController.dispose(); + _codeController.dispose(); super.dispose(); } Future _handleLogin() async { final cubit = context.read(); - cubit.emailChanged(_emailController.text); - cubit.passwordChanged(_passwordController.text); + cubit.phoneChanged(_phoneController.text); + cubit.codeChanged(_codeController.text); if (!cubit.state.isValid) { return; @@ -64,120 +69,243 @@ class _LoginViewState extends State { } } - @override - Widget build(BuildContext context) { - return AuthPageScaffold( - mainContentKey: const Key('login_main_content'), - footerKey: const Key('login_footer'), - mainContent: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 380), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const AuthHeroHeader(showBrand: true), - SizedBox(height: AppSpacing.xxl), - BlocBuilder( - builder: (context, state) { - final fieldError = state.email.displayError != null - ? state.email.error - : state.password.displayError != null - ? state.password.error - : null; + Future _handleSendCode() async { + if (!_agreedToTerms) { + final confirmed = await _showAgreementDialog(); + if (!confirmed || !mounted) return; + setState(() => _agreedToTerms = true); + } - return AuthSurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - '登录账号', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - SizedBox(height: AppSpacing.xs), - SizedBox(height: AppSpacing.xl), - AuthSection( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthField( - label: '邮箱', - hint: 'name@example.com', - controller: _emailController, - keyboardType: TextInputType.emailAddress, - ), - SizedBox(height: AppSpacing.lg), - PasswordField( - controller: _passwordController, - label: '密码', - hint: '请输入密码', - ), - if (state.errorMessage != null || - fieldError != null) - Padding( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - ), - child: AppBanner( - message: - state.errorMessage ?? fieldError!, - type: state.errorMessage != null - ? ToastType.error - : ToastType.warning, - title: state.errorMessage != null - ? '登录失败' - : '请检查输入', - ), - ), - ], - ), - ), - SizedBox(height: AppSpacing.xl), - AppButton( - text: '登录', - onPressed: - state.status == FormzSubmissionStatus.inProgress - ? null - : _handleLogin, - isLoading: - state.status == - FormzSubmissionStatus.inProgress, - ), - SizedBox(height: AppSpacing.sm), - Align( - alignment: Alignment.centerRight, - child: LinkButton( - text: '忘记密码?', - onTap: () => context.push('/reset-password'), - ), - ), - ], + final cubit = context.read(); + cubit.phoneChanged(_phoneController.text); + final sent = await cubit.sendCode(); + if (!mounted || !sent) { + return; + } + } + + Future _showAgreementDialog() async { + return await showConfirmSheet( + context, + title: '请先同意协议', + message: '在使用我们的服务之前,请先阅读并同意《用户协议》和《隐私政策》。\n\n只有您同意上述协议,我们才能为您提供服务。', + confirmText: '确认', + cancelText: '取消', + ); + } + + Widget _buildAgreementCheckbox() { + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Semantics( + label: '同意用户协议与隐私政策', + checked: _agreedToTerms, + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + onTap: () => setState(() => _agreedToTerms = !_agreedToTerms), + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(right: AppSpacing.sm), + decoration: BoxDecoration( + color: _agreedToTerms + ? AppColors.blue600 + : Colors.transparent, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: _agreedToTerms + ? AppColors.blue600 + : AppColors.slate400, + width: 1.5, ), - ); - }, + ), + child: _agreedToTerms + ? const Icon( + Icons.check, + size: 14, + color: AppColors.white, + ) + : null, + ), + ), + ), + ), + ), + RichText( + text: TextSpan( + style: const TextStyle(fontSize: 13, color: AppColors.slate600), + children: [ + const TextSpan(text: '我已同意'), + TextSpan( + text: '《用户协议》', + style: const TextStyle( + color: AppColors.blue600, + decoration: TextDecoration.underline, + ), + ), + const TextSpan(text: '与'), + TextSpan( + text: '《隐私政策》', + style: const TextStyle( + color: AppColors.blue600, + decoration: TextDecoration.underline, + ), ), ], ), ), - ), - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '还没有账号?', - style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted), - ), - LinkButton(text: '去注册', onTap: () => context.push('/register')), ], ), ); } + + @override + Widget build(BuildContext context) { + return AuthPageScaffold( + resizeOnKeyboard: false, + mainContentKey: const Key('login_main_content'), + mainContent: LayoutBuilder( + builder: (context, constraints) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + bottomInset + AppSpacing.lg, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AuthHeroHeader(showBrand: true), + SizedBox(height: AppSpacing.xxl), + BlocBuilder( + builder: (context, state) { + final fieldError = state.phone.displayError != null + ? state.phone.error + : state.code.displayError != null + ? state.code.error + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthSection( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + AuthField( + hint: '输入手机号', + controller: _phoneController, + onChanged: (value) { + context.read().phoneChanged( + value, + ); + }, + keyboardType: TextInputType.phone, + prefix: PhonePrefixSelector( + value: state.dialCode, + items: _dialCodes, + onChanged: (value) { + context + .read() + .dialCodeChanged(value); + }, + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(14), + ], + ), + SizedBox(height: AppSpacing.lg), + AuthField( + hint: '输入验证码', + controller: _codeController, + onChanged: (value) { + context.read().codeChanged( + value, + ); + }, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + suffixIcon: Padding( + padding: const EdgeInsets.only( + right: AppSpacing.md, + ), + child: LinkButton( + text: state.resendCooldownSeconds > 0 + ? '${state.resendCooldownSeconds}s' + : '发送验证码', + onTap: state.canSendCode + ? _handleSendCode + : null, + ), + ), + ), + if (state.errorMessage != null || + fieldError != null) + Padding( + padding: const EdgeInsets.only( + top: AppSpacing.md, + ), + child: AppBanner( + message: + state.errorMessage ?? fieldError!, + type: state.errorMessage != null + ? ToastType.error + : ToastType.warning, + title: state.errorMessage != null + ? '登录失败' + : '请检查输入', + ), + ), + ], + ), + ), + SizedBox(height: AppSpacing.xxl), + AppButton( + text: '登录/注册', + onPressed: + state.status == + FormzSubmissionStatus.inProgress + ? null + : _handleLogin, + isLoading: + state.status == + FormzSubmissionStatus.inProgress, + ), + SizedBox(height: AppSpacing.xxl * 2), + _buildAgreementCheckbox(), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } } diff --git a/apps/lib/features/auth/ui/screens/register_screen.dart b/apps/lib/features/auth/ui/screens/register_screen.dart deleted file mode 100644 index ee411cf..0000000 --- a/apps/lib/features/auth/ui/screens/register_screen.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:formz/formz.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../../core/di/injection.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/banner/app_banner.dart'; -import '../../../../shared/widgets/fixed_length_code_input.dart'; -import '../../../../shared/widgets/link_button.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/auth_repository.dart'; -import '../../presentation/cubits/register_cubit.dart'; -import '../widgets/auth_field.dart'; -import '../widgets/auth_page_scaffold.dart'; -import '../widgets/password_field.dart'; - -class RegisterScreen extends StatelessWidget { - const RegisterScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => RegisterCubit(sl()), - child: const RegisterView(), - ); - } -} - -class RegisterView extends StatefulWidget { - const RegisterView({super.key}); - - @override - State createState() => _RegisterViewState(); -} - -class _RegisterViewState extends State { - static const _inviteCodeLength = 4; - static const _inviteAllowedChars = { - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'J', - 'K', - 'M', - 'N', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - }; - - final _nicknameController = TextEditingController(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _inviteCodeController = TextEditingController(); - - @override - void dispose() { - _nicknameController.dispose(); - _emailController.dispose(); - _passwordController.dispose(); - _inviteCodeController.dispose(); - super.dispose(); - } - - Future _handleNext() async { - final cubit = context.read(); - final inviteCode = _inviteCodeController.text.trim().toUpperCase(); - final normalizedInviteCode = inviteCode.length == _inviteCodeLength - ? inviteCode - : ''; - - cubit.usernameChanged(_nicknameController.text); - cubit.emailChanged(_emailController.text); - cubit.passwordChanged(_passwordController.text); - cubit.inviteCodeChanged(normalizedInviteCode); - - if (!cubit.state.isStep1Valid || cubit.state.isSending) { - String? errorMsg; - if (!cubit.state.username.isValid) { - errorMsg = '请输入有效的昵称(3-30个字符)'; - } else if (!cubit.state.email.isValid) { - errorMsg = '请输入有效的邮箱地址'; - } else if (!cubit.state.password.isValid) { - errorMsg = '密码至少需要6个字符'; - } - if (errorMsg != null && mounted) { - Toast.show(context, errorMsg, type: ToastType.warning); - } - return; - } - - if (inviteCode.isNotEmpty && normalizedInviteCode.isEmpty && mounted) { - Toast.show( - context, - '邀请码需为 4 位,且仅支持 A-H/J-N/P-Z 与 2-9;已按无邀请码继续注册', - type: ToastType.warning, - ); - } - - if (mounted) { - context.push('/register/verification', extra: cubit); - } - - unawaited(cubit.sendCodeSilently()); - } - - @override - Widget build(BuildContext context) { - return AuthPageScaffold( - mainContent: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 388), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const AuthHeroHeader(showBrand: true), - SizedBox(height: AppSpacing.xxl), - BlocBuilder( - builder: (context, state) { - return AuthSurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - '创建账号', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - SizedBox(height: AppSpacing.lg), - AuthSection( - title: '基础信息', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthField( - label: '昵称', - hint: '请输入昵称(3-30字符)', - controller: _nicknameController, - ), - SizedBox(height: AppSpacing.lg), - AuthField( - label: '邮箱', - hint: 'name@example.com', - controller: _emailController, - keyboardType: TextInputType.emailAddress, - ), - SizedBox(height: AppSpacing.lg), - PasswordField( - controller: _passwordController, - label: '密码', - hint: '请输入至少 6 位密码', - ), - ], - ), - ), - SizedBox(height: AppSpacing.md), - AuthSection( - title: '邀请码(选填)', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FixedLengthCodeInput( - controller: _inviteCodeController, - length: _inviteCodeLength, - semanticLabel: '邀请码输入框', - uppercase: true, - allowedCharacters: _inviteAllowedChars, - onChanged: (value) { - context - .read() - .inviteCodeChanged(value); - }, - ), - SizedBox(height: AppSpacing.md), - AppBanner( - title: '邀请码', - message: '4 位,支持 A-H/J-N/P-Z 与 2-9。', - type: ToastType.info, - ), - ], - ), - ), - if (state.errorMessage != null) ...[ - SizedBox(height: AppSpacing.lg), - AppBanner( - title: '注册失败', - message: state.errorMessage!, - type: ToastType.error, - ), - ], - SizedBox(height: AppSpacing.lg), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Container( - height: 6, - decoration: BoxDecoration( - color: AppColors.authPrimaryButton, - borderRadius: BorderRadius.circular( - AppRadius.full, - ), - ), - ), - ), - SizedBox(width: AppSpacing.sm), - Expanded( - child: Container( - height: 6, - decoration: BoxDecoration( - color: AppColors.authSectionBorder, - borderRadius: BorderRadius.circular( - AppRadius.full, - ), - ), - ), - ), - ], - ), - SizedBox(height: AppSpacing.sm), - const Text( - '第 1 步:完善基础信息', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.authLinkMuted, - ), - ), - SizedBox(height: AppSpacing.lg), - AppButton( - text: '下一步', - onPressed: - state.status == - FormzSubmissionStatus.inProgress || - state.isSending - ? null - : _handleNext, - isLoading: state.isSending, - ), - ], - ), - ); - }, - ), - ], - ), - ), - ), - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '已有账号?', - style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted), - ), - LinkButton(text: '去登录', onTap: () => context.pop()), - ], - ), - ); - } -} diff --git a/apps/lib/features/auth/ui/screens/register_verification_screen.dart b/apps/lib/features/auth/ui/screens/register_verification_screen.dart deleted file mode 100644 index e2c5a8b..0000000 --- a/apps/lib/features/auth/ui/screens/register_verification_screen.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:formz/formz.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../../core/router/app_routes.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/banner/app_banner.dart'; -import '../../../../shared/widgets/fixed_length_code_input.dart'; -import '../../../../shared/widgets/link_button.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../presentation/bloc/auth_bloc.dart'; -import '../../presentation/bloc/auth_event.dart'; -import '../../presentation/cubits/register_cubit.dart'; -import '../widgets/auth_page_scaffold.dart'; - -class RegisterVerificationScreen extends StatelessWidget { - const RegisterVerificationScreen({super.key, this.cubit}); - - final RegisterCubit? cubit; - - @override - Widget build(BuildContext context) { - final registerCubit = - cubit ?? (GoRouterState.of(context).extra as RegisterCubit?); - - if (registerCubit == null) { - return const Scaffold( - body: Center(child: Text('RegisterCubit not found')), - ); - } - - return BlocProvider.value( - value: registerCubit, - child: const RegisterVerificationView(), - ); - } -} - -class RegisterVerificationView extends StatefulWidget { - const RegisterVerificationView({super.key}); - - @override - State createState() => - _RegisterVerificationViewState(); -} - -class _RegisterVerificationViewState extends State { - final _codeController = TextEditingController(); - Timer? _countdownTimer; - int _countdown = 0; - bool _firstSendCompleted = false; - - @override - void dispose() { - _countdownTimer?.cancel(); - _codeController.dispose(); - super.dispose(); - } - - void _startCountdown() { - setState(() { - _countdown = 60; - }); - _countdownTimer?.cancel(); - _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_countdown > 0) { - setState(() { - _countdown--; - }); - } else { - timer.cancel(); - } - }); - } - - Future _handleComplete() async { - final cubit = context.read(); - cubit.verificationCodeChanged(_codeController.text); - - if (!cubit.state.isStep2Valid) { - Toast.show( - context, - _codeController.text.isEmpty ? '请输入验证码' : '验证码必须是 6 位数字', - type: ToastType.warning, - ); - return; - } - - final response = await cubit.submitStep2(); - if (response != null && mounted) { - context.read().add(AuthLoggedIn(user: response.user)); - context.go(AppRoutes.homeMain); - } - } - - Future _handleResendCode() async { - final cubit = context.read(); - final success = await cubit.resendCode(); - - if (success && mounted) { - _startCountdown(); - Toast.show(context, '验证码已发送', type: ToastType.success); - } - } - - @override - Widget build(BuildContext context) { - return AuthPageScaffold( - mainContent: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 388), - child: BlocConsumer( - listener: (context, state) { - if (!mounted) { - return; - } - - if (state.status == FormzSubmissionStatus.failure && - state.errorMessage != null) { - Toast.show(context, state.errorMessage!, type: ToastType.error); - - if (!_firstSendCompleted) { - _firstSendCompleted = true; - setState(() { - _countdown = 0; - }); - } - } - - if (state.status == FormzSubmissionStatus.success && - !_firstSendCompleted) { - _firstSendCompleted = true; - _startCountdown(); - Toast.show(context, '验证码已发送', type: ToastType.info); - } - }, - builder: (context, state) { - final isSubmitting = - state.status == FormzSubmissionStatus.inProgress; - final canResend = _countdown == 0 && !isSubmitting; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const AuthHeroHeader(showBrand: true), - SizedBox(height: AppSpacing.xl), - AuthSurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - '验证邮箱', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - SizedBox(height: AppSpacing.lg), - AuthSection( - title: '验证码', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FixedLengthCodeInput( - controller: _codeController, - length: 6, - semanticLabel: '邮箱验证码输入框', - keyboardType: TextInputType.number, - allowedCharacters: const { - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - }, - onChanged: (value) { - context - .read() - .verificationCodeChanged(value); - }, - ), - SizedBox(height: AppSpacing.md), - AppBanner( - title: '验证码', - message: canResend - ? '6 位数字验证码。' - : '$_countdown 秒后可重新发送。', - type: ToastType.info, - ), - ], - ), - ), - SizedBox(height: AppSpacing.lg), - AppButton( - text: '完成注册', - onPressed: isSubmitting ? null : _handleComplete, - isLoading: isSubmitting, - ), - SizedBox(height: AppSpacing.sm), - Center( - child: LinkButton( - text: canResend ? '重新发送验证码' : '$_countdown 秒后重发', - onTap: canResend ? _handleResendCode : null, - enabled: canResend, - ), - ), - ], - ), - ), - ], - ); - }, - ), - ), - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '已有账号?', - style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted), - ), - LinkButton(text: '去登录', onTap: () => context.go('/')), - ], - ), - ); - } -} diff --git a/apps/lib/features/auth/ui/screens/reset_password_screen.dart b/apps/lib/features/auth/ui/screens/reset_password_screen.dart deleted file mode 100644 index 262ae13..0000000 --- a/apps/lib/features/auth/ui/screens/reset_password_screen.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:formz/formz.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../../core/di/injection.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/banner/app_banner.dart'; -import '../../../../shared/widgets/fixed_length_code_input.dart'; -import '../../../../shared/widgets/link_button.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/auth_repository.dart'; -import '../../presentation/cubits/reset_password_cubit.dart'; -import '../widgets/auth_field.dart'; -import '../widgets/auth_page_scaffold.dart'; -import '../widgets/password_field.dart'; - -class ResetPasswordScreen extends StatelessWidget { - const ResetPasswordScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ResetPasswordCubit(sl()), - child: const ResetPasswordView(), - ); - } -} - -class ResetPasswordView extends StatefulWidget { - const ResetPasswordView({super.key}); - - @override - State createState() => _ResetPasswordViewState(); -} - -class _ResetPasswordViewState extends State { - final _emailController = TextEditingController(); - final _codeController = TextEditingController(); - final _passwordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - - @override - void dispose() { - _emailController.dispose(); - _codeController.dispose(); - _passwordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - Future _handleSubmit() async { - final cubit = context.read(); - cubit.emailChanged(_emailController.text); - cubit.codeChanged(_codeController.text); - cubit.newPasswordChanged(_passwordController.text); - cubit.confirmPasswordChanged(_confirmPasswordController.text); - - await cubit.submit(); - } - - void _handleSendCode(ResetPasswordState state) { - if (state.codeSent) { - context.read().resendCode(); - return; - } - - context.read().sendCode(); - } - - @override - Widget build(BuildContext context) { - return BlocListener( - listenWhen: (previous, current) => - previous.status != current.status || - previous.errorMessage != current.errorMessage || - previous.codeSent != current.codeSent, - listener: (context, state) { - if (state.status == FormzSubmissionStatus.success && state.isSuccess) { - Toast.show(context, '密码重置成功,请使用新密码登录', type: ToastType.success); - context.go('/'); - } else if (state.status == FormzSubmissionStatus.success && - state.codeSent && - state.errorMessage == 'CODE_SENT_SUCCESS') { - Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success); - } else if (state.status == FormzSubmissionStatus.failure && - state.errorMessage != null && - state.errorMessage != '' && - state.errorMessage != 'CODE_SENT_SUCCESS') { - Toast.show(context, state.errorMessage!, type: ToastType.error); - } - }, - child: AuthPageScaffold( - mainContent: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 392), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const AuthHeroHeader(title: '忘记密码'), - SizedBox(height: AppSpacing.xxl), - BlocBuilder( - builder: (context, state) { - final isSending = - state.status == FormzSubmissionStatus.inProgress && - !state.codeSent; - final isSubmitting = - state.status == FormzSubmissionStatus.inProgress && - state.codeSent; - - return AuthSurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - '找回访问权限', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - ), - SizedBox(height: AppSpacing.xs), - SizedBox(height: AppSpacing.xl), - AuthSection( - title: '第 1 步:验证邮箱', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthField( - label: '邮箱', - hint: 'name@example.com', - controller: _emailController, - keyboardType: TextInputType.emailAddress, - onChanged: (value) { - context - .read() - .emailChanged(value); - }, - ), - SizedBox(height: AppSpacing.lg), - AppButton( - text: state.resendCountdown > 0 - ? '${state.resendCountdown} 秒后可重发' - : state.codeSent - ? '重新发送验证码' - : '发送验证码', - onPressed: - state.resendCountdown > 0 || isSending - ? null - : () => _handleSendCode(state), - isOutlined: state.codeSent, - isLoading: isSending, - ), - ], - ), - ), - if (state.codeSent) ...[ - SizedBox(height: AppSpacing.lg), - AuthSection( - title: '第 2 步:输入验证码并设置新密码', - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - AppBanner( - title: '验证码已发送', - message: state.resendCountdown > 0 - ? '如未收到邮件,可在 ${state.resendCountdown} 秒后重新发送。' - : '如果没有收到邮件,可以再次发送验证码。', - type: ToastType.info, - ), - SizedBox(height: AppSpacing.lg), - const Text( - '验证码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.slate700, - ), - ), - SizedBox(height: AppSpacing.sm), - FixedLengthCodeInput( - controller: _codeController, - length: 6, - semanticLabel: '重置密码验证码输入框', - keyboardType: TextInputType.number, - allowedCharacters: const { - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - }, - onChanged: (value) { - context - .read() - .codeChanged(value); - }, - ), - SizedBox(height: AppSpacing.lg), - PasswordField( - controller: _passwordController, - label: '新密码', - hint: '请输入新密码(至少 6 位)', - onChanged: (value) { - context - .read() - .newPasswordChanged(value); - }, - ), - SizedBox(height: AppSpacing.lg), - PasswordField( - controller: _confirmPasswordController, - label: '确认密码', - hint: '请再次输入新密码', - onChanged: (value) { - context - .read() - .confirmPasswordChanged(value); - }, - ), - ], - ), - ), - SizedBox(height: AppSpacing.xl), - AppButton( - text: '重置密码', - onPressed: isSubmitting ? null : _handleSubmit, - isLoading: isSubmitting, - ), - ], - ], - ), - ); - }, - ), - ], - ), - ), - ), - ), - footer: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '想起密码了?', - style: TextStyle(fontSize: 14, color: AppColors.authLinkMuted), - ), - LinkButton(text: '返回登录', onTap: () => context.go('/')), - ], - ), - ), - ); - } -} diff --git a/apps/lib/features/auth/ui/widgets/auth_field.dart b/apps/lib/features/auth/ui/widgets/auth_field.dart index 0321307..af9c9f9 100644 --- a/apps/lib/features/auth/ui/widgets/auth_field.dart +++ b/apps/lib/features/auth/ui/widgets/auth_field.dart @@ -1,41 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../../core/theme/design_tokens.dart'; class AuthField extends StatelessWidget { const AuthField({ super.key, - required this.label, + this.label, required this.hint, required this.controller, this.keyboardType, this.obscureText = false, this.suffixIcon, this.onChanged, + this.prefix, + this.inputFormatters, }); - final String label; + final String? label; final String hint; final TextEditingController controller; final TextInputType? keyboardType; final bool obscureText; final Widget? suffixIcon; final ValueChanged? onChanged; + final Widget? prefix; + final List? inputFormatters; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.slate700, + if (label != null) ...[ + Text( + label!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.slate700, + ), ), - ), - SizedBox(height: AppSpacing.sm), + SizedBox(height: AppSpacing.sm), + ], Semantics( label: label, textField: true, @@ -44,6 +51,7 @@ class AuthField extends StatelessWidget { keyboardType: keyboardType, obscureText: obscureText, onChanged: onChanged, + inputFormatters: inputFormatters, style: const TextStyle(fontSize: 16, color: AppColors.slate900), decoration: InputDecoration( hintText: hint, @@ -57,6 +65,7 @@ class AuthField extends StatelessWidget { horizontal: AppSpacing.lg, vertical: AppSpacing.lg, ), + prefixIcon: prefix, suffixIcon: suffixIcon, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.lg), diff --git a/apps/lib/features/auth/ui/widgets/auth_page_scaffold.dart b/apps/lib/features/auth/ui/widgets/auth_page_scaffold.dart index 569ac67..a3316d0 100644 --- a/apps/lib/features/auth/ui/widgets/auth_page_scaffold.dart +++ b/apps/lib/features/auth/ui/widgets/auth_page_scaffold.dart @@ -9,12 +9,14 @@ class AuthPageScaffold extends StatelessWidget { this.footer, this.mainContentKey, this.footerKey, + this.resizeOnKeyboard = true, }); final Widget mainContent; final Widget? footer; final Key? mainContentKey; final Key? footerKey; + final bool resizeOnKeyboard; @override Widget build(BuildContext context) { @@ -22,6 +24,7 @@ class AuthPageScaffold extends StatelessWidget { return Scaffold( backgroundColor: AppColors.authBackgroundBottom, + resizeToAvoidBottomInset: resizeOnKeyboard, body: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( @@ -37,8 +40,33 @@ class AuthPageScaffold extends StatelessWidget { children: [ const _AuthBackgroundOrbs(), SafeArea( + maintainBottomViewPadding: !resizeOnKeyboard, child: LayoutBuilder( builder: (context, constraints) { + if (!resizeOnKeyboard) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + KeyedSubtree( + key: mainContentKey, + child: mainContent, + ), + if (footer != null) ...[ + SizedBox(height: AppSpacing.md), + KeyedSubtree(key: footerKey, child: footer!), + ], + ], + ), + ), + ); + } + return SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, diff --git a/apps/lib/features/auth/ui/widgets/password_field.dart b/apps/lib/features/auth/ui/widgets/password_field.dart index 0d1cbda..c4a7edb 100644 --- a/apps/lib/features/auth/ui/widgets/password_field.dart +++ b/apps/lib/features/auth/ui/widgets/password_field.dart @@ -7,13 +7,13 @@ class PasswordField extends StatefulWidget { const PasswordField({ super.key, required this.controller, - required this.label, + this.label, required this.hint, this.onChanged, }); final TextEditingController controller; - final String label; + final String? label; final String hint; final ValueChanged? onChanged; diff --git a/apps/lib/features/users/data/models/user_response.dart b/apps/lib/features/users/data/models/user_response.dart index 081de5b..e7c2899 100644 --- a/apps/lib/features/users/data/models/user_response.dart +++ b/apps/lib/features/users/data/models/user_response.dart @@ -1,14 +1,14 @@ class UserResponse { final String id; final String username; - final String? email; + final String? phone; final String? avatarUrl; final String? bio; const UserResponse({ required this.id, required this.username, - this.email, + this.phone, this.avatarUrl, this.bio, }); @@ -17,7 +17,7 @@ class UserResponse { return UserResponse( id: json['id'] as String, username: json['username'] as String, - email: json['email'] as String?, + phone: json['phone'] as String?, avatarUrl: json['avatar_url'] as String?, bio: json['bio'] as String?, ); diff --git a/apps/lib/shared/utils/validators.dart b/apps/lib/shared/utils/validators.dart index 640a74c..782dc06 100644 --- a/apps/lib/shared/utils/validators.dart +++ b/apps/lib/shared/utils/validators.dart @@ -1,13 +1,13 @@ class Validators { Validators._(); - static String? email(String? value) { + static String? phone(String? value) { if (value == null || value.isEmpty) { - return '请输入邮箱'; + return '请输入手机号'; } - final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); - if (!emailRegex.hasMatch(value)) { - return '请输入有效的邮箱地址'; + final phoneRegex = RegExp(r'^\+861[3-9]\d{9}$'); + if (!phoneRegex.hasMatch(value)) { + return '请输入有效的 +86 手机号'; } return null; } diff --git a/apps/test/core/startup/auth_session_bootstrapper_test.dart b/apps/test/core/startup/auth_session_bootstrapper_test.dart index 997e3f9..fc90abc 100644 --- a/apps/test/core/startup/auth_session_bootstrapper_test.dart +++ b/apps/test/core/startup/auth_session_bootstrapper_test.dart @@ -52,7 +52,7 @@ void main() { await bootstrapper.syncForAuthState( const AuthAuthenticated( - user: AuthUser(id: 'u1', email: 'a@test.com'), + user: AuthUser(id: 'u1', phone: 'a@test.com'), ), ); @@ -68,7 +68,7 @@ void main() { await bootstrapper.syncForAuthState( const AuthAuthenticated( - user: AuthUser(id: 'u1', email: 'a@test.com'), + user: AuthUser(id: 'u1', phone: 'a@test.com'), ), ); @@ -84,7 +84,7 @@ void main() { await bootstrapper.syncForAuthState( const AuthAuthenticated( - user: AuthUser(id: 'u1', email: 'a@test.com'), + user: AuthUser(id: 'u1', phone: 'a@test.com'), ), ); diff --git a/apps/test/features/auth/data/auth_repository_test.dart b/apps/test/features/auth/data/auth_repository_test.dart index be4269f..542c264 100644 --- a/apps/test/features/auth/data/auth_repository_test.dart +++ b/apps/test/features/auth/data/auth_repository_test.dart @@ -6,14 +6,11 @@ import 'package:social_app/features/auth/data/models/signup_request.dart'; import 'package:social_app/features/auth/data/models/login_request.dart'; import 'package:social_app/features/auth/data/models/auth_response.dart'; import 'package:social_app/core/storage/token_storage.dart'; -import 'package:social_app/core/api/api_client.dart'; class MockAuthApi extends Mock implements AuthApi {} class MockTokenStorage extends Mock implements TokenStorage {} -class MockApiClient extends Mock implements ApiClient {} - void main() { late AuthRepositoryImpl repository; late MockAuthApi mockApi; @@ -23,43 +20,29 @@ void main() { mockApi = MockAuthApi(); mockStorage = MockTokenStorage(); repository = AuthRepositoryImpl(api: mockApi, tokenStorage: mockStorage); - registerFallbackValue( - const SignupStartRequest(username: '', email: '', password: ''), - ); - registerFallbackValue(const LoginRequest(email: '', password: '')); - registerFallbackValue(const SignupVerifyRequest(email: '', token: '')); - registerFallbackValue(const SignupResendRequest(email: '')); + registerFallbackValue(const OtpSendRequest(phone: '')); + registerFallbackValue(const LoginRequest(phone: '', token: '')); registerFallbackValue(const LogoutRequest(refreshToken: '')); registerFallbackValue(const RefreshRequest(refreshToken: '')); }); group('AuthRepositoryImpl', () { - test('createVerification calls api and returns response', () async { - when(() => mockApi.createVerification(any())).thenAnswer( - (_) async => - const VerificationCreateResponse(email: 'test@example.com'), - ); + test('sendOtp calls api', () async { + when(() => mockApi.sendOtp(any())).thenAnswer((_) async {}); - final result = await repository.createVerification( - const SignupStartRequest( - username: 'testuser', - email: 'test@example.com', - password: 'password123', - ), - ); + await repository.sendOtp('+8613812345678'); - expect(result.email, 'test@example.com'); - verify(() => mockApi.createVerification(any())).called(1); + verify(() => mockApi.sendOtp(any())).called(1); }); - test('createSession calls api and saves tokens', () async { - when(() => mockApi.createSession(any())).thenAnswer( + test('createPhoneSession calls api and saves tokens', () async { + when(() => mockApi.createPhoneSession(any())).thenAnswer( (_) async => AuthResponse( accessToken: 'access_token', refreshToken: 'refresh_token', expiresIn: 3600, tokenType: 'bearer', - user: const AuthUser(id: '123', email: 'test@example.com'), + user: const AuthUser(id: '123', phone: '+8613812345678'), ), ); when( @@ -69,8 +52,9 @@ void main() { ), ).thenAnswer((_) async {}); - final result = await repository.createSession( - const LoginRequest(email: 'test@example.com', password: 'password123'), + final result = await repository.createPhoneSession( + phone: '+8613812345678', + token: '123456', ); expect(result.accessToken, 'access_token'); @@ -117,7 +101,7 @@ void main() { refreshToken: 'new_refresh', expiresIn: 3600, tokenType: 'bearer', - user: const AuthUser(id: '123', email: 'test@example.com'), + user: const AuthUser(id: '123', phone: '+8613812345678'), ), ); when( diff --git a/apps/test/features/auth/data/models/auth_models_test.dart b/apps/test/features/auth/data/models/auth_models_test.dart index 7641de1..f77848e 100644 --- a/apps/test/features/auth/data/models/auth_models_test.dart +++ b/apps/test/features/auth/data/models/auth_models_test.dart @@ -4,33 +4,32 @@ import 'package:social_app/features/auth/data/models/login_request.dart'; import 'package:social_app/features/auth/data/models/auth_response.dart'; void main() { - group('SignupStartRequest', () { - test('serializes to JSON', () { - final request = SignupStartRequest( - username: 'testuser', - email: 'test@example.com', - password: 'password123', - ); + group('OtpSendRequest', () { + test('serializes e164 phone to JSON', () { + final request = OtpSendRequest(phone: '+14155552671'); final json = request.toJson(); - expect(json['username'], 'testuser'); - expect(json['email'], 'test@example.com'); - expect(json['password'], 'password123'); + expect(json['phone'], '+14155552671'); + }); + + test('normalizes 00 prefix to plus', () { + final request = OtpSendRequest(phone: '0014155552671'); + + final json = request.toJson(); + + expect(json['phone'], '+14155552671'); }); }); group('LoginRequest', () { - test('serializes to JSON', () { - final request = LoginRequest( - email: 'test@example.com', - password: 'password123', - ); + test('serializes e164 to JSON', () { + final request = LoginRequest(phone: '+14155552671', token: '123456'); final json = request.toJson(); - expect(json['email'], 'test@example.com'); - expect(json['password'], 'password123'); + expect(json['phone'], '+14155552671'); + expect(json['token'], '123456'); }); }); @@ -41,7 +40,7 @@ void main() { 'refresh_token': 'test_refresh', 'expires_in': 3600, 'token_type': 'bearer', - 'user': {'id': '123', 'email': 'test@example.com'}, + 'user': {'id': '123', 'phone': '+8613812345678'}, }; final response = AuthResponse.fromJson(json); @@ -50,7 +49,7 @@ void main() { expect(response.refreshToken, 'test_refresh'); expect(response.expiresIn, 3600); expect(response.user.id, '123'); - expect(response.user.email, 'test@example.com'); + expect(response.user.phone, '+8613812345678'); }); }); } diff --git a/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart index 497fd2c..1795f05 100644 --- a/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart +++ b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -50,7 +50,7 @@ void main() { refreshToken: 'new_refresh', expiresIn: 3600, tokenType: 'bearer', - user: const AuthUser(id: '123', email: 'test@example.com'), + user: const AuthUser(id: '123', phone: '+8613812345678'), ), ); return authBloc; @@ -107,7 +107,7 @@ void main() { build: () => authBloc, act: (bloc) => bloc.add( AuthLoggedIn( - user: const AuthUser(id: '1', email: 'test@example.com'), + user: const AuthUser(id: '1', phone: '+8613812345678'), ), ), expect: () => [isA()], @@ -120,7 +120,7 @@ void main() { return authBloc; }, seed: () => AuthAuthenticated( - user: const AuthUser(id: '1', email: 'test@example.com'), + user: const AuthUser(id: '1', phone: '+8613812345678'), ), act: (bloc) => bloc.add(AuthLoggedOut()), expect: () => [ @@ -137,7 +137,7 @@ void main() { return authBloc; }, seed: () => AuthAuthenticated( - user: const AuthUser(id: '1', email: 'test@example.com'), + user: const AuthUser(id: '1', phone: '+8613812345678'), ), act: (bloc) => bloc.add( const AuthSessionInvalidated( diff --git a/apps/test/features/auth/presentation/cubits/login_cubit_test.dart b/apps/test/features/auth/presentation/cubits/login_cubit_test.dart index 3403ff7..fab811e 100644 --- a/apps/test/features/auth/presentation/cubits/login_cubit_test.dart +++ b/apps/test/features/auth/presentation/cubits/login_cubit_test.dart @@ -1,4 +1,5 @@ import 'package:bloc_test/bloc_test.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:formz/formz.dart'; import 'package:mocktail/mocktail.dart'; @@ -26,17 +27,68 @@ void main() { }); blocTest( - 'emailChanged updates email', + 'phoneChanged updates phone', build: () => cubit, - act: (c) => c.emailChanged('test@example.com'), + act: (c) => c.phoneChanged('+8613812345678'), expect: () => [isA()], ); blocTest( - 'passwordChanged updates password', + 'codeChanged updates code', build: () => cubit, - act: (c) => c.passwordChanged('password123'), + act: (c) => c.codeChanged('123456'), expect: () => [isA()], ); + + test('sendCode success starts 60s cooldown', () { + when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {}); + + fakeAsync((async) { + cubit.phoneChanged('13812345678'); + + cubit.sendCode(); + async.flushMicrotasks(); + + expect(cubit.state.resendCooldownSeconds, 60); + + async.elapse(const Duration(seconds: 1)); + expect(cubit.state.resendCooldownSeconds, 59); + + async.elapse(const Duration(seconds: 59)); + expect(cubit.state.resendCooldownSeconds, 0); + }); + }); + + test('sendCode is blocked during cooldown', () async { + when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {}); + cubit.phoneChanged('13812345678'); + + final first = await cubit.sendCode(); + final second = await cubit.sendCode(); + + expect(first, isTrue); + expect(second, isFalse); + verify(() => mockRepository.sendOtp(any())).called(1); + }); + + test('phone change resets cooldown and code state', () { + when(() => mockRepository.sendOtp(any())).thenAnswer((_) async {}); + + fakeAsync((async) { + cubit.phoneChanged('13812345678'); + cubit.codeChanged('123456'); + cubit.sendCode(); + async.flushMicrotasks(); + + expect(cubit.state.resendCooldownSeconds, 60); + expect(cubit.state.codeSent, isTrue); + + cubit.phoneChanged('14155552671'); + + expect(cubit.state.resendCooldownSeconds, 0); + expect(cubit.state.codeSent, isFalse); + expect(cubit.state.code.value, ''); + }); + }); }); } diff --git a/apps/test/features/auth/presentation/cubits/register_cubit_test.dart b/apps/test/features/auth/presentation/cubits/register_cubit_test.dart deleted file mode 100644 index 00f99b6..0000000 --- a/apps/test/features/auth/presentation/cubits/register_cubit_test.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:formz/formz.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:social_app/core/form_inputs/form_inputs.dart'; -import 'package:social_app/features/auth/data/auth_repository.dart'; -import 'package:social_app/features/auth/data/models/auth_response.dart'; -import 'package:social_app/features/auth/data/models/signup_request.dart'; -import 'package:social_app/features/auth/presentation/cubits/register_cubit.dart'; -import 'package:social_app/core/api/api_exception.dart'; - -class MockAuthRepository extends Mock implements AuthRepository {} - -void main() { - late RegisterCubit cubit; - late MockAuthRepository mockRepository; - - setUp(() { - mockRepository = MockAuthRepository(); - cubit = RegisterCubit(mockRepository); - registerFallbackValue( - SignupStartRequest(username: '', email: '', password: ''), - ); - registerFallbackValue(SignupResendRequest(email: '')); - }); - - tearDown(() { - cubit.close(); - }); - - group('RegisterCubit', () { - test('initial state has pure status', () { - expect(cubit.state.status, FormzSubmissionStatus.initial); - }); - - blocTest( - 'usernameChanged updates username', - build: () => cubit, - act: (c) => c.usernameChanged('testuser'), - expect: () => [isA()], - ); - - blocTest( - 'emailChanged updates email', - build: () => cubit, - act: (c) => c.emailChanged('test@example.com'), - expect: () => [isA()], - ); - - blocTest( - 'passwordChanged updates password', - build: () => cubit, - act: (c) => c.passwordChanged('password123'), - expect: () => [isA()], - ); - }); - - group('sendCodeSilently', () { - blocTest( - 'sets isSending to true then false on success', - build: () => cubit, - seed: () => RegisterState( - username: const Username.dirty('testuser'), - email: const Email.dirty('test@example.com'), - password: const Password.dirty('password123'), - ), - setUp: () { - when(() => mockRepository.createVerification(any())).thenAnswer( - (_) async => VerificationCreateResponse(email: 'test@example.com'), - ); - }, - act: (c) => c.sendCodeSilently(), - expect: () => [ - predicate((state) => state.isSending == true), - predicate( - (state) => - state.isSending == false && - state.codeSent == true && - state.pendingEmail == 'test@example.com' && - state.errorMessage == null, - ), - ], - verify: (_) { - verify(() => mockRepository.createVerification(any())).called(1); - }, - ); - - blocTest( - 'restores isSending to false and sets errorMessage and failure status on error', - build: () => cubit, - seed: () => RegisterState( - username: const Username.dirty('testuser'), - email: const Email.dirty('test@example.com'), - password: const Password.dirty('password123'), - ), - setUp: () { - when( - () => mockRepository.createVerification(any()), - ).thenThrow(ServerException('Network error')); - }, - act: (c) => c.sendCodeSilently(), - expect: () => [ - predicate((state) => state.isSending == true), - predicate( - (state) => - state.isSending == false && - state.errorMessage == 'Network error' && - state.status == FormzSubmissionStatus.failure, - ), - ], - verify: (_) { - verify(() => mockRepository.createVerification(any())).called(1); - }, - ); - - blocTest( - 'does not call createVerification when input is invalid', - build: () => cubit, - seed: () => RegisterState( - username: const Username.dirty(''), - email: const Email.dirty('invalid'), - password: const Password.dirty(''), - ), - act: (c) => c.sendCodeSilently(), - expect: () => [], - verify: (_) { - verifyNever(() => mockRepository.createVerification(any())); - }, - ); - - blocTest( - 'returns early when isSending is true', - build: () => cubit, - seed: () => RegisterState( - username: const Username.dirty('testuser'), - email: const Email.dirty('test@example.com'), - password: const Password.dirty('password123'), - isSending: true, - ), - act: (c) => c.sendCodeSilently(), - expect: () => [], - verify: (_) { - verifyNever(() => mockRepository.createVerification(any())); - }, - ); - }); - - group('resendCode', () { - blocTest( - 'returns false and sets failure status when pendingEmail is null', - build: () => cubit, - seed: () => RegisterState(pendingEmail: null), - act: (c) => c.resendCode(), - expect: () => [ - isA() - .having((s) => s.status, 'status', FormzSubmissionStatus.failure) - .having((s) => s.errorMessage, 'errorMessage', '验证码发送失败,请返回上一步重试'), - ], - verify: (_) { - verifyNever(() => mockRepository.resendVerification(any())); - }, - ); - - blocTest( - 'returns true and sets status on success', - build: () => cubit, - seed: () => RegisterState(pendingEmail: 'test@example.com'), - setUp: () { - when( - () => mockRepository.resendVerification(any()), - ).thenAnswer((_) async {}); - }, - act: (c) => c.resendCode(), - expect: () => [ - isA().having( - (s) => s.status, - 'status', - FormzSubmissionStatus.inProgress, - ), - isA().having( - (s) => s.status, - 'status', - FormzSubmissionStatus.success, - ), - ], - verify: (_) { - verify(() => mockRepository.resendVerification(any())).called(1); - }, - ); - - blocTest( - 'returns false on error', - build: () => cubit, - seed: () => RegisterState(pendingEmail: 'test@example.com'), - setUp: () { - when( - () => mockRepository.resendVerification(any()), - ).thenThrow(ServerException('Network error')); - }, - act: (c) => c.resendCode(), - expect: () => [ - isA().having( - (s) => s.status, - 'status', - FormzSubmissionStatus.inProgress, - ), - isA().having( - (s) => s.status, - 'status', - FormzSubmissionStatus.failure, - ), - ], - ); - }); -} diff --git a/apps/test/features/auth/presentation/cubits/reset_password_cubit_test.dart b/apps/test/features/auth/presentation/cubits/reset_password_cubit_test.dart deleted file mode 100644 index 037609a..0000000 --- a/apps/test/features/auth/presentation/cubits/reset_password_cubit_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:formz/formz.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:social_app/features/auth/data/auth_repository.dart'; -import 'package:social_app/features/auth/presentation/cubits/reset_password_cubit.dart'; - -class MockAuthRepository extends Mock implements AuthRepository {} - -void main() { - late ResetPasswordCubit cubit; - late MockAuthRepository mockRepository; - - setUp(() { - mockRepository = MockAuthRepository(); - cubit = ResetPasswordCubit(mockRepository); - }); - - tearDown(() async { - await cubit.close(); - }); - - test( - 'sendCode enters countdown immediately and prevents duplicate clicks', - () async { - final completer = Completer(); - when( - () => mockRepository.requestPasswordReset(any()), - ).thenAnswer((_) => completer.future); - - cubit.emailChanged('test@example.com'); - - final firstRequest = cubit.sendCode(); - await Future.delayed(Duration.zero); - - expect(cubit.state.status, FormzSubmissionStatus.inProgress); - expect(cubit.state.codeSent, isTrue); - expect(cubit.state.resendCountdown, 60); - - await cubit.sendCode(); - verify( - () => mockRepository.requestPasswordReset('test@example.com'), - ).called(1); - - completer.complete(); - await firstRequest; - }, - ); - - test('sendCode failure cancels countdown and restores retry state', () async { - when( - () => mockRepository.requestPasswordReset(any()), - ).thenThrow(Exception('network error')); - - cubit.emailChanged('test@example.com'); - - await cubit.sendCode(); - - expect(cubit.state.status, FormzSubmissionStatus.failure); - expect(cubit.state.codeSent, isFalse); - expect(cubit.state.resendCountdown, 0); - expect(cubit.state.errorMessage, '网络错误,请稍后重试'); - }); -}