diff --git a/docs/plans/2026-10-22-login-error-center-dialog.md b/docs/plans/2026-10-22-login-error-center-dialog.md new file mode 100644 index 0000000..fe5847f --- /dev/null +++ b/docs/plans/2026-10-22-login-error-center-dialog.md @@ -0,0 +1,28 @@ +# 登录异常居中弹窗 + +## 目标 + +- 邮箱登录、邮箱注册及手机号 OTP 登录的提交异常改为屏幕居中弹窗。 +- 本地表单校验继续使用现有内联字段错误,不触发弹窗。 + +## 实施步骤 + +1. 新增 `lib/core/widgets/centered_error_dialog.dart`,统一弹窗样式、遮罩关闭和确认按钮。 +2. 补充登录失败、注册失败、确认按钮的中英文印尼语文案并重新生成 l10n。 +3. 调整邮箱认证页:接口异常展示居中弹窗,提交 busy 在展示弹窗前复位。 +4. 调整手机号弹窗:OTP 发送和验证码登录/注册异常展示居中弹窗,成功 SnackBar 保持不变。 +5. 补接口异常、注册标题、本地校验不弹窗和手机号两条异常路径测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 同级 FruitServer 的 `INBOX.md` 当前顶部为已处理 K100,未找到本需求条目;以用户消息中的完整需求为准。 +- 现有主工作区处于 `feature/payment-iap` 且有未提交 `pubspec.lock`,本次使用独立 worktree,不触碰该文件。 + +## 执行结果 + +- 已新增居中错误弹窗组件及三语言文案,邮箱登录/注册和手机号 OTP/验证码提交异常均改为弹窗。 +- 本地字段校验仍走原内联错误;异常展示前提交 busy 已复位。 +- 接口异常、注册标题、未知异常网络文案、遮罩关闭、确认关闭、本地校验不弹窗均有测试覆盖。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 570 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 2122f93..c34a0c5 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -203,3 +203,33 @@ Running Gradle task 'assembleDebug'... 35.7s 解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-22 登录异常改为屏幕居中弹窗 + +- 已从 `origin/develop`(`9b78e43`)创建 `feature/login-error-center-dialog`。 +- 新增 `lib/core/widgets/centered_error_dialog.dart`,统一居中样式、遮罩关闭、确认按钮。 +- 邮箱登录/注册与手机号 OTP/验证码异常改为居中弹窗;字段校验保持内联,成功提示不变。 +- 新增 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 三语言文案并重新生成 l10n。 +- 异常弹窗前提交 busy 已复位,避免 loading 卡在弹窗打开期间。 + +执行命令与原始输出: + +```text +$ flutter gen-l10n +Because l10n.yaml exists, the options defined there will be used instead. +To use the command line arguments, delete the l10n.yaml file in the Flutter project. + +$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart +00:02 +20: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:49 +570: All tests passed! +``` + +解读:登录异常、注册标题、未知异常回退、本地校验不弹窗、遮罩/按钮关闭和手机号两条 +异常路径均通过;全量静态分析零告警,570 项测试全过。`pubspec.lock` 已还原, +未启停服务,未操作真机。 diff --git a/lib/core/widgets/centered_error_dialog.dart b/lib/core/widgets/centered_error_dialog.dart new file mode 100644 index 0000000..5a36621 --- /dev/null +++ b/lib/core/widgets/centered_error_dialog.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../theme/app_colors.dart'; + +const Key _dialogKey = Key('centered-error-dialog'); +const Key _confirmKey = Key('centered-error-confirm'); +const double _dialogRadius = 16; +const double _titleHorizontalInset = 24; +const double _titleTopInset = 24; +const double _contentHorizontalInset = 24; +const double _contentTopInset = 12; +const double _contentBottomInset = 8; +const double _actionsHorizontalInset = 16; +const double _actionsBottomInset = 12; +const EdgeInsets _titlePadding = EdgeInsets.fromLTRB( + _titleHorizontalInset, + _titleTopInset, + _titleHorizontalInset, + 0, +); +const EdgeInsets _contentPadding = EdgeInsets.fromLTRB( + _contentHorizontalInset, + _contentTopInset, + _contentHorizontalInset, + _contentBottomInset, +); +const EdgeInsets _actionsPadding = EdgeInsets.fromLTRB( + _actionsHorizontalInset, + 0, + _actionsHorizontalInset, + _actionsBottomInset, +); + +/// Shows a reusable error dialog centered on the screen. +Future showCenteredErrorDialog( + BuildContext context, { + required String title, + required String message, +}) async { + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) => AlertDialog( + key: _dialogKey, + backgroundColor: AppColors.card, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_dialogRadius), + ), + titlePadding: _titlePadding, + contentPadding: _contentPadding, + title: Text(title), + content: Text(message), + actionsPadding: _actionsPadding, + actions: [ + TextButton( + key: _confirmKey, + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.authErrorConfirm), + ), + ], + ), + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 526f378..528a3bf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -435,6 +435,9 @@ "authPasswordLength": "Password must be 8-64 characters", "authPasswordWeak": "Password needs both letters and numbers", "authNetworkError": "Network error, please try again", + "authLoginFailed": "Login failed", + "authRegisterFailed": "Registration failed", + "authErrorConfirm": "OK", "authSwitchToSignUp": "New here? Create an account", "authSwitchToSignIn": "Already have an account? Sign in", "authGateTitle": "Sign in to continue", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b707d25..f2995ce 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -409,6 +409,9 @@ "authPasswordLength": "Kata sandi harus 8-64 karakter", "authPasswordWeak": "Kata sandi perlu huruf dan angka", "authNetworkError": "Jaringan bermasalah, coba lagi", + "authLoginFailed": "Gagal masuk", + "authRegisterFailed": "Pendaftaran gagal", + "authErrorConfirm": "Mengerti", "authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignIn": "Sudah punya akun? Masuk", "authGateTitle": "Masuk untuk melanjutkan", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 447651c..914bbf9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1888,6 +1888,24 @@ abstract class AppLocalizations { /// **'Network error, please try again'** String get authNetworkError; + /// No description provided for @authLoginFailed. + /// + /// In en, this message translates to: + /// **'Login failed'** + String get authLoginFailed; + + /// No description provided for @authRegisterFailed. + /// + /// In en, this message translates to: + /// **'Registration failed'** + String get authRegisterFailed; + + /// No description provided for @authErrorConfirm. + /// + /// In en, this message translates to: + /// **'OK'** + String get authErrorConfirm; + /// No description provided for @authSwitchToSignUp. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d36f78e..95459ce 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -958,6 +958,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authNetworkError => 'Network error, please try again'; + @override + String get authLoginFailed => 'Login failed'; + + @override + String get authRegisterFailed => 'Registration failed'; + + @override + String get authErrorConfirm => 'OK'; + @override String get authSwitchToSignUp => 'New here? Create an account'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index b5c5ee0..f048f14 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -962,6 +962,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get authNetworkError => 'Jaringan bermasalah, coba lagi'; + @override + String get authLoginFailed => 'Gagal masuk'; + + @override + String get authRegisterFailed => 'Pendaftaran gagal'; + + @override + String get authErrorConfirm => 'Mengerti'; + @override String get authSwitchToSignUp => 'Baru di sini? Buat akun'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3bfa8c4..47411c3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -946,6 +946,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authNetworkError => '网络异常,请稍后重试'; + @override + String get authLoginFailed => '登录失败'; + + @override + String get authRegisterFailed => '注册失败'; + + @override + String get authErrorConfirm => '知道了'; + @override String get authSwitchToSignUp => '还没有账号?去注册'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 32ecfa9..805b5b8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -371,6 +371,9 @@ "authPasswordLength": "密码长度需在 8-64 位之间", "authPasswordWeak": "密码需同时包含字母和数字", "authNetworkError": "网络异常,请稍后重试", + "authLoginFailed": "登录失败", + "authRegisterFailed": "注册失败", + "authErrorConfirm": "知道了", "authSwitchToSignUp": "还没有账号?去注册", "authSwitchToSignIn": "已有账号?去登录", "authGateTitle": "登录后可继续", diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 59b38b2..dfd01a9 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -12,6 +12,7 @@ import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/centered_error_dialog.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; @@ -227,6 +228,7 @@ class _AuthPageState extends State { _logic.errorMessage = null; }); final auth = context.read(); + String? failureMessage; try { if (_logic.signUpMode) { await auth.register( @@ -242,14 +244,18 @@ class _AuthPageState extends State { if (!mounted) return; Navigator.of(context).maybePop(); } on ApiException catch (error) { - if (!mounted) return; - setState(() => _logic.errorMessage = error.message); + failureMessage = error.message; } catch (_) { - if (!mounted) return; - setState(() => _logic.errorMessage = l10n.authNetworkError); + failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _logic.busy = false); } + if (!mounted || failureMessage == null) return; + await showCenteredErrorDialog( + context, + title: _logic.signUpMode ? l10n.authRegisterFailed : l10n.authLoginFailed, + message: failureMessage, + ); } @override diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index c37f6c7..7702249 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/centered_error_dialog.dart'; import '../../l10n/app_localizations.dart'; const double _fieldHeight = 52; @@ -164,6 +165,7 @@ class _PhoneAuthSheetState extends State { }); final haptics = context.read(); final auth = context.read(); + String? failureMessage; await haptics.lightImpact(); try { await auth.sendOtp( @@ -173,16 +175,20 @@ class _PhoneAuthSheetState extends State { if (!mounted) return; _startCountdown(); setState(() => _error = null); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { - if (mounted) setState(() => _error = error.message); + failureMessage = error.message; } catch (_) { - if (mounted) setState(() => _error = l10n.authNetworkError); + failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _sending = false); } + if (mounted && failureMessage != null) { + await _showFailure(failureMessage); + } } void _startCountdown() { @@ -212,6 +218,7 @@ class _PhoneAuthSheetState extends State { }); final haptics = context.read(); final auth = context.read(); + String? failureMessage; await haptics.lightImpact(); try { if (_signUp) { @@ -221,12 +228,24 @@ class _PhoneAuthSheetState extends State { } if (mounted) Navigator.of(context).pop(true); } on ApiException catch (error) { - if (mounted) setState(() => _error = error.message); + failureMessage = error.message; } catch (_) { - if (mounted) setState(() => _error = l10n.authNetworkError); + failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _submitting = false); } + if (mounted && failureMessage != null) { + await _showFailure(failureMessage); + } + } + + Future _showFailure(String message) { + final l10n = AppLocalizations.of(context)!; + return showCenteredErrorDialog( + context, + title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed, + message: message, + ); } void _switchMode() { diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 1e02e3c..923f450 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -78,6 +78,7 @@ void main() { expect(find.text('Enter your email'), findsOneWidget); expect(find.text('Welcome back'), findsOneWidget); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); }); testWidgets( @@ -94,6 +95,80 @@ void main() { find.text('Password needs both letters and numbers'), findsOneWidget, ); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + }, + ); + + testWidgets('should_show_centered_dialog_when_login_api_returns_an_error', ( + tester, + ) async { + final api = _FailingAuthApi(loginMessage: 'Invalid email or password'); + await pumpAuthPage(tester, api: api); + await _enterValidCredentials(tester); + + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + final dialog = find.byKey(const Key('centered-error-dialog')); + expect(dialog, findsOneWidget); + expect(find.text('Login failed'), findsOneWidget); + expect(find.text('Invalid email or password'), findsOneWidget); + expect(api.loginCalls, 1); + expect( + tester.getCenter(dialog), + offsetMoreOrLessEquals( + tester.getCenter(find.byType(Scaffold)), + epsilon: 1, + ), + ); + expect( + tester + .widget(find.byKey(const Key('auth-submit'))) + .onPressed, + isNotNull, + ); + + await tester.tap(find.byKey(const Key('centered-error-confirm'))); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + }); + + testWidgets( + 'should_show_registration_failed_title_when_register_api_returns_an_error', + (tester) async { + final api = _FailingAuthApi(registerMessage: 'Email already exists'); + await pumpAuthPage(tester, signUp: true, api: api); + await _enterValidCredentials(tester); + + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Registration failed'), findsOneWidget); + expect(find.text('Email already exists'), findsOneWidget); + expect(api.registerCalls, 1); + + await tester.tapAt(const Offset(8, 8)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + }, + ); + + testWidgets( + 'should_show_network_error_when_login_throws_an_unknown_exception', + (tester) async { + final api = _FailingAuthApi(loginThrowsUnknown: true); + await pumpAuthPage(tester, api: api); + await _enterValidCredentials(tester); + + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Network error, please try again'), findsOneWidget); }, ); @@ -342,6 +417,11 @@ void main() { }); } +Future _enterValidCredentials(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); +} + void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; @@ -351,6 +431,50 @@ void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { ); } +class _FailingAuthApi extends AuthApi { + _FailingAuthApi({ + this.loginMessage = 'login failed', + this.registerMessage = 'register failed', + this.loginThrowsUnknown = false, + }) : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final String loginMessage; + final String registerMessage; + final bool loginThrowsUnknown; + int loginCalls = 0; + int registerCalls = 0; + + @override + Future login({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginCalls++; + if (loginThrowsUnknown) throw StateError('unexpected'); + throw ApiException(401, loginMessage); + } + + @override + Future register({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + registerCalls++; + throw ApiException(409, registerMessage); + } +} + class _PendingAuthApi extends AuthApi { _PendingAuthApi() : super( diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 036b286..3c3e2d8 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; @@ -97,6 +98,64 @@ void main() { expect(find.text('Enter a valid phone number'), findsOneWidget); expect(adapter.requests, isEmpty); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + }); + + testWidgets('should_show_centered_dialog_when_otp_request_fails', ( + tester, + ) async { + final api = _FailingPhoneAuthApi(sendOtpMessage: 'Too many OTP requests'); + final auth = AuthController(api, AuthSessionStore()); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('auth-phone-number')), + '08123456789', + ); + + await tester.tap(find.byKey(const Key('auth-send-code'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Login failed'), findsOneWidget); + expect(find.text('Too many OTP requests'), findsOneWidget); + expect(api.sendOtpCalls, 1); + expect( + tester + .widget(find.byKey(const Key('auth-send-code'))) + .onPressed, + isNotNull, + ); + }); + + testWidgets('should_show_centered_dialog_when_phone_login_fails', ( + tester, + ) async { + final api = _FailingPhoneAuthApi(loginMessage: 'Invalid verification code'); + final auth = AuthController(api, AuthSessionStore()); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('auth-phone-number')), + '08123456789', + ); + await tester.enterText(find.byKey(const Key('auth-phone-code')), '123456'); + + await tester.tap(find.byKey(const Key('auth-phone-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Login failed'), findsOneWidget); + expect(find.text('Invalid verification code'), findsOneWidget); + expect(api.loginWithPhoneCalls, 1); + expect( + tester + .widget(find.byKey(const Key('auth-phone-submit'))) + .onPressed, + isNotNull, + ); }); testWidgets('should_keep_code_above_ios_keyboard', (tester) async { @@ -132,6 +191,45 @@ void main() { }); } +class _FailingPhoneAuthApi extends AuthApi { + _FailingPhoneAuthApi({ + this.sendOtpMessage = 'otp failed', + this.loginMessage = 'login failed', + }) : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final String sendOtpMessage; + final String loginMessage; + int sendOtpCalls = 0; + int loginWithPhoneCalls = 0; + + @override + Future sendOtp({ + required String target, + required String scene, + required String deviceId, + }) async { + sendOtpCalls++; + throw ApiException(429, sendOtpMessage); + } + + @override + Future loginWithPhone({ + required String target, + required String code, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginWithPhoneCalls++; + throw ApiException(401, loginMessage); + } +} + Future _pumpHost(WidgetTester tester, AuthController auth) { return tester.pumpWidget( MultiProvider(