feat: 实现密码重置功能与用户搜索API,优化注册登录流程
- 新增忘记密码页面与重置密码确认流程(前端+后端) - 修复注册验证码页登录跳转路由 - 新增用户搜索API(按邮箱查询) - 简化infra脚本,统一为app.sh - 补充密码重置与用户API测试覆盖 - 更新runtime文档与AGENTS配置
This commit is contained in:
+57
-20
@@ -1,16 +1,55 @@
|
||||
## Mobile Rules
|
||||
# Flutter Mobile Development Rules
|
||||
|
||||
- Flutter mobile rules are maintained here.
|
||||
- If no more specific rule is defined here, follow the root `AGENTS.md`.
|
||||
This document defines Flutter mobile development constraints.
|
||||
|
||||
## Flutter Design-to-Code Workflow
|
||||
## Design System
|
||||
|
||||
Before writing any Flutter UI code, follow this sequence:
|
||||
### Design Tokens
|
||||
|
||||
1. **Get editor state**: Use `pencil_get_editor_state` to confirm the active design.
|
||||
2. **Get structure**: Use `pencil_batch_get` to inspect node hierarchy and layout.
|
||||
3. **Get variables**: Use `pencil_get_variables` to fetch colors, typography, and tokens.
|
||||
4. **Implement**: Match design values and container hierarchy exactly.
|
||||
All UI styling must use design tokens from `apps/lib/core/theme/design_tokens.dart`:
|
||||
|
||||
| Type | Usage |
|
||||
|------|-------|
|
||||
| Colors | `AppColors.primary`, `AppColors.slate500`, `AppColors.background` |
|
||||
| Spacing | `AppSpacing.xs`, `AppSpacing.sm`, `AppSpacing.md` |
|
||||
| Radius | `AppRadius.sm`, `AppRadius.md`, `AppRadius.lg` |
|
||||
|
||||
**NEVER hardcode colors, sizes, or spacing values.**
|
||||
|
||||
### Reuse Existing Components
|
||||
|
||||
Use pre-built components instead of creating custom ones:
|
||||
- Buttons: Use `AppButton` widget from `apps/lib/shared/widgets/app_button.dart`
|
||||
- Input fields: Use standard Flutter `TextField` with `InputDecoration`
|
||||
- Loading states: Use built-in loading indicators
|
||||
|
||||
## New Page Design Workflow
|
||||
|
||||
1. **Analyze existing pages**: Study login, register, home screens for:
|
||||
- Layout structure (centered form, padding, spacing)
|
||||
- Typography hierarchy (title 28px bold, label 13px, hint 14px)
|
||||
- Component usage (AppButton, TextField style)
|
||||
- Color and spacing tokens
|
||||
|
||||
2. **Use frontend-design skill for mockups**:
|
||||
```
|
||||
Use the `frontend-design` skill to create HTML/CSS mockups for review
|
||||
Match colors to `apps/lib/core/theme/design_tokens.dart`
|
||||
Match spacing to `AppSpacing` values
|
||||
Match radius to `AppRadius` values
|
||||
```
|
||||
|
||||
3. **Verify design tokens**:
|
||||
- All colors from `AppColors`
|
||||
- All spacing from `AppSpacing`
|
||||
- All radius from `AppRadius`
|
||||
- NO hardcoded values
|
||||
|
||||
4. **Code review checklist**:
|
||||
- [ ] All colors/spacing/radius use design tokens
|
||||
- [ ] Reuses existing components (AppButton)
|
||||
- [ ] Consistent with existing page patterns
|
||||
- [ ] No magic numbers
|
||||
|
||||
## Layout Mapping Rules
|
||||
|
||||
@@ -21,15 +60,13 @@ Map design layout properties to Flutter explicitly:
|
||||
- `alignItems: start` -> `CrossAxisAlignment.start`
|
||||
- `alignItems: stretch` -> `CrossAxisAlignment.stretch`
|
||||
2. **Map full container chain**: From root to leaf, ensure each `alignItems` and `justifyContent` has a Flutter equivalent.
|
||||
3. **Analyze before coding**: Use `pencil_snapshot_layout` or `pencil_batch_get` to verify each container's alignment settings.
|
||||
3. **Analyze before coding**: Verify each container's alignment settings.
|
||||
|
||||
## Centering and Visual Balance
|
||||
|
||||
Apply these rules on any screen that relies on centered composition:
|
||||
|
||||
1. Centering must be evaluated inside **`SafeArea` bounds**, not full-screen bounds.
|
||||
1. Centering must be evaluated inside **`SafeArea`** bounds, not full-screen bounds.
|
||||
2. Avoid relying on proportional `Spacer` values as the only centering mechanism for critical content.
|
||||
3. For layouts with persistent top/bottom regions (for example headers or footers), center the primary content in the remaining available region.
|
||||
3. For layouts with persistent top/bottom regions (e.g., headers or footers), center the primary content in the remaining available region.
|
||||
4. Distinguish geometric centering from visual centering; validate final visual balance with screenshot review.
|
||||
|
||||
## Quality Gate
|
||||
@@ -41,10 +78,10 @@ For important screens, add widget tests that reduce layout-regression risk:
|
||||
|
||||
## Prohibitions
|
||||
|
||||
- Do not use colors or themes not defined in the design.
|
||||
- Do not skip design container layers.
|
||||
- Do not start implementation before retrieving design variables.
|
||||
- Do not hardcode colors; use design variables.
|
||||
- DO NOT use colors not defined in design tokens
|
||||
- DO NOT skip design container layers
|
||||
- DO NOT start implementation before retrieving design variables
|
||||
- DO NOT hardcode colors; use design variables
|
||||
|
||||
## UI Feedback System
|
||||
|
||||
@@ -82,5 +119,5 @@ AppBanner(message: '请检查输入', type: ToastType.warning)
|
||||
|
||||
- Use `Toast` for transient feedback that auto-dismisses
|
||||
- Use `AppBanner` for persistent inline messages (form errors)
|
||||
- Do NOT create custom SnackBar, Dialog, or Banner components
|
||||
- Do NOT use raw `ScaffoldMessenger`
|
||||
- DO NOT create custom SnackBar, Dialog, or Banner components
|
||||
- DO NOT use raw `ScaffoldMessenger`
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'go_router_refresh_stream.dart';
|
||||
import '../../features/auth/ui/screens/login_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/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';
|
||||
@@ -67,6 +68,10 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
path: '/register/verification',
|
||||
builder: (context, state) => const RegisterVerificationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/reset-password',
|
||||
builder: (context, state) => const ResetPasswordScreen(),
|
||||
),
|
||||
GoRoute(path: '/home', builder: (context, state) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/messages/invites',
|
||||
|
||||
@@ -50,4 +50,19 @@ class AuthApi {
|
||||
Future<void> deleteSession(LogoutRequest request) async {
|
||||
await _client.delete('$_prefix/sessions', data: request.toJson());
|
||||
}
|
||||
|
||||
Future<void> requestPasswordReset(String email) async {
|
||||
await _client.post('$_prefix/password-reset', data: {'email': email});
|
||||
}
|
||||
|
||||
Future<void> confirmPasswordReset({
|
||||
required String email,
|
||||
required String token,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
await _client.post(
|
||||
'$_prefix/password-reset/confirm',
|
||||
data: {'email': email, 'token': token, 'new_password': newPassword},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,10 @@ abstract class AuthRepository {
|
||||
Future<String?> getAccessToken();
|
||||
Future<String?> getRefreshToken();
|
||||
Future<bool> isAuthenticated();
|
||||
Future<void> requestPasswordReset(String email);
|
||||
Future<void> confirmPasswordReset({
|
||||
required String email,
|
||||
required String token,
|
||||
required String newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,4 +77,22 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
final token = await _tokenStorage.getAccessToken();
|
||||
return token != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPasswordReset(String email) {
|
||||
return _api.requestPasswordReset(email);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> confirmPasswordReset({
|
||||
required String email,
|
||||
required String token,
|
||||
required String newPassword,
|
||||
}) {
|
||||
return _api.confirmPasswordReset(
|
||||
email: email,
|
||||
token: token,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@ class SignupStartRequest {
|
||||
final String username;
|
||||
final String email;
|
||||
final String password;
|
||||
final String? inviteCode;
|
||||
|
||||
const SignupStartRequest({
|
||||
required this.username,
|
||||
required this.email,
|
||||
required this.password,
|
||||
this.inviteCode,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
if (inviteCode != null) 'invite_code': inviteCode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class RegisterState extends Equatable {
|
||||
final Email email;
|
||||
final Password password;
|
||||
final VerificationCode verificationCode;
|
||||
final String inviteCode;
|
||||
final FormzSubmissionStatus status;
|
||||
final String? errorMessage;
|
||||
final String? pendingEmail;
|
||||
@@ -23,6 +24,7 @@ class RegisterState extends Equatable {
|
||||
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,
|
||||
@@ -39,6 +41,7 @@ class RegisterState extends Equatable {
|
||||
Email? email,
|
||||
Password? password,
|
||||
VerificationCode? verificationCode,
|
||||
String? inviteCode,
|
||||
FormzSubmissionStatus? status,
|
||||
String? errorMessage,
|
||||
String? pendingEmail,
|
||||
@@ -50,6 +53,7 @@ class RegisterState extends Equatable {
|
||||
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,
|
||||
@@ -64,6 +68,7 @@ class RegisterState extends Equatable {
|
||||
email,
|
||||
password,
|
||||
verificationCode,
|
||||
inviteCode,
|
||||
status,
|
||||
errorMessage,
|
||||
pendingEmail,
|
||||
@@ -93,6 +98,10 @@ class RegisterCubit extends Cubit<RegisterState> {
|
||||
emit(state.copyWith(verificationCode: VerificationCode.dirty(value)));
|
||||
}
|
||||
|
||||
void inviteCodeChanged(String value) {
|
||||
emit(state.copyWith(inviteCode: value));
|
||||
}
|
||||
|
||||
Future<bool> submitStep1() async {
|
||||
if (!state.isStep1Valid) return false;
|
||||
|
||||
@@ -104,6 +113,7 @@ class RegisterCubit extends Cubit<RegisterState> {
|
||||
username: state.username.value,
|
||||
email: state.email.value,
|
||||
password: state.password.value,
|
||||
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
|
||||
),
|
||||
);
|
||||
emit(
|
||||
@@ -202,6 +212,7 @@ class RegisterCubit extends Cubit<RegisterState> {
|
||||
username: state.username.value,
|
||||
email: state.email.value,
|
||||
password: state.password.value,
|
||||
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
|
||||
),
|
||||
);
|
||||
emit(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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<Object?> get props => [
|
||||
email,
|
||||
code,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
status,
|
||||
errorMessage,
|
||||
isSuccess,
|
||||
resendCountdown,
|
||||
codeSent,
|
||||
];
|
||||
}
|
||||
|
||||
class ResetPasswordCubit extends Cubit<ResetPasswordState> {
|
||||
final AuthRepository _repository;
|
||||
Timer? _resendTimer;
|
||||
|
||||
ResetPasswordCubit(this._repository) : super(const ResetPasswordState());
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<void> 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<void> 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: '密码重置失败,请检查验证码',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,8 @@ class _LoginViewState extends State<LoginView> {
|
||||
? null
|
||||
: _handleLogin,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildForgotPassword(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -236,6 +238,20 @@ class _LoginViewState extends State<LoginView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForgotPassword() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/reset-password'),
|
||||
child: const Text(
|
||||
'忘记密码?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/register'),
|
||||
|
||||
@@ -36,6 +36,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
final _nicknameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _inviteCodeController = TextEditingController();
|
||||
bool _obscureText = true;
|
||||
|
||||
@override
|
||||
@@ -43,6 +44,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
_nicknameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_inviteCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
cubit.usernameChanged(_nicknameController.text);
|
||||
cubit.emailChanged(_emailController.text);
|
||||
cubit.passwordChanged(_passwordController.text);
|
||||
cubit.inviteCodeChanged(_inviteCodeController.text);
|
||||
|
||||
if (!cubit.state.isStep1Valid || cubit.state.isSending) {
|
||||
String? errorMsg;
|
||||
@@ -159,6 +162,8 @@ class _RegisterViewState extends State<RegisterView> {
|
||||
const SizedBox(height: 12),
|
||||
_buildPasswordInput(),
|
||||
const SizedBox(height: 12),
|
||||
_buildInput('邀请码(选填)', '请输入邀请码', _inviteCodeController),
|
||||
const SizedBox(height: 12),
|
||||
_buildStepIndicator(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
|
||||
@@ -48,10 +48,22 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
||||
Timer? _countdownTimer;
|
||||
int _countdown = 0;
|
||||
bool _firstSendCompleted = false;
|
||||
bool _hintShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_hintShown) {
|
||||
_hintShown = true;
|
||||
Toast.show(
|
||||
context,
|
||||
'验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册',
|
||||
type: ToastType.info,
|
||||
duration: const Duration(seconds: 5),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -331,7 +343,7 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
|
||||
|
||||
Widget _buildFooter() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
onTap: () => context.go('/'),
|
||||
child: const Text(
|
||||
'已有账号?去登录',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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/theme/design_tokens.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../presentation/cubits/reset_password_cubit.dart';
|
||||
import '../../data/auth_repository.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatelessWidget {
|
||||
const ResetPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ResetPasswordCubit(sl<AuthRepository>()),
|
||||
child: const ResetPasswordView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResetPasswordView extends StatefulWidget {
|
||||
const ResetPasswordView({super.key});
|
||||
|
||||
@override
|
||||
State<ResetPasswordView> createState() => _ResetPasswordViewState();
|
||||
}
|
||||
|
||||
class _ResetPasswordViewState extends State<ResetPasswordView> {
|
||||
final _emailController = TextEditingController();
|
||||
final _codeController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_codeController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
final cubit = context.read<ResetPasswordCubit>();
|
||||
cubit.emailChanged(_emailController.text);
|
||||
cubit.codeChanged(_codeController.text);
|
||||
cubit.newPasswordChanged(_passwordController.text);
|
||||
cubit.confirmPasswordChanged(_confirmPasswordController.text);
|
||||
|
||||
await cubit.submit();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<ResetPasswordCubit, ResetPasswordState>(
|
||||
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: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildTitle(),
|
||||
const SizedBox(height: 32),
|
||||
_buildFormContainer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
return const Text(
|
||||
'忘记密码',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormContainer() {
|
||||
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
|
||||
builder: (context, state) {
|
||||
return SizedBox(
|
||||
width: 327,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildEmailInput(state.email.displayError != null),
|
||||
const SizedBox(height: 12),
|
||||
_buildCodeInput(state.code.displayError != null, state),
|
||||
const SizedBox(height: 12),
|
||||
_buildPasswordInput(state.newPassword.displayError != null),
|
||||
const SizedBox(height: 12),
|
||||
_buildConfirmPasswordInput(
|
||||
state.confirmPassword.displayError != null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSubmitButton(state),
|
||||
const SizedBox(height: 16),
|
||||
_buildBackToLogin(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmailInput(bool hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'邮箱',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().emailChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入邮箱',
|
||||
errorText: hasError ? ' ' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeInput(bool hasError, ResetPasswordState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _codeController,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().codeChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入 6 位验证码',
|
||||
errorText: hasError ? ' ' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
state.resendCountdown > 0 ||
|
||||
state.status == FormzSubmissionStatus.inProgress
|
||||
? null
|
||||
: () {
|
||||
if (state.codeSent) {
|
||||
context.read<ResetPasswordCubit>().resendCode();
|
||||
} else {
|
||||
context.read<ResetPasswordCubit>().sendCode();
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: state.codeSent
|
||||
? AppColors.background
|
||||
: AppColors.primary,
|
||||
foregroundColor: state.codeSent
|
||||
? AppColors.primary
|
||||
: AppColors.primaryForeground,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
),
|
||||
child: Text(
|
||||
state.resendCountdown > 0
|
||||
? '${state.resendCountdown}秒'
|
||||
: (state.codeSent ? '重新发送' : '发送验证码'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordInput(bool hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'新密码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().newPasswordChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入新密码(至少 6 位)',
|
||||
errorText: hasError ? ' ' : null,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
size: 20,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmPasswordInput(bool hasError) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'确认密码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().confirmPasswordChanged(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '请再次输入新密码',
|
||||
errorText: hasError ? ' ' : null,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
size: 20,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton(ResetPasswordState state) {
|
||||
final isLoading = state.status == FormzSubmissionStatus.inProgress;
|
||||
final isDisabled = isLoading || !state.codeSent;
|
||||
|
||||
return AppButton(
|
||||
text: '重置密码',
|
||||
onPressed: isDisabled ? null : _handleSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackToLogin() {
|
||||
return GestureDetector(
|
||||
onTap: () => context.go('/'),
|
||||
child: const Text(
|
||||
'返回登录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,12 @@ class UsersApi {
|
||||
return UserResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<UserResponse> getByUsername(String username) async {
|
||||
final response = await _client.get('$_prefix/$username');
|
||||
return UserResponse.fromJson(response.data);
|
||||
Future<List<UserResponse>> searchUsers(String query) async {
|
||||
final response = await _client.post(
|
||||
'$_prefix/search',
|
||||
data: {'query': query},
|
||||
);
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => UserResponse.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ import 'models/user_response.dart';
|
||||
abstract class UsersRepository {
|
||||
Future<UserResponse> getMe();
|
||||
Future<UserResponse> updateMe(UserUpdateRequest request);
|
||||
Future<UserResponse> getByUsername(String username);
|
||||
Future<List<UserResponse>> searchUsers(String query);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class UsersRepositoryImpl implements UsersRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserResponse> getByUsername(String username) {
|
||||
return _api.getByUsername(username);
|
||||
Future<List<UserResponse>> searchUsers(String query) {
|
||||
return _api.searchUsers(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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<void>();
|
||||
when(
|
||||
() => mockRepository.requestPasswordReset(any()),
|
||||
).thenAnswer((_) => completer.future);
|
||||
|
||||
cubit.emailChanged('test@example.com');
|
||||
|
||||
final firstRequest = cubit.sendCode();
|
||||
await Future<void>.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, '网络错误,请稍后重试');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user