feat(auth): 登录异常改为居中弹窗
This commit is contained in:
1 parent
9b78e43702
commit
b0002abf82
14 files changed
+436
-10
No files matched your search
@@ -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 项全过。
|
||||||
|
- 未启停服务,未操作真机。
|
||||||
@@ -203,3 +203,33 @@ Running Gradle task 'assembleDebug'... 35.7s
|
|||||||
|
|
||||||
解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功;
|
解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功;
|
||||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
`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` 已还原,
|
||||||
|
未启停服务,未操作真机。
|
||||||
@@ -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<void> showCenteredErrorDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
await showDialog<void>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -435,6 +435,9 @@
|
|||||||
"authPasswordLength": "Password must be 8-64 characters",
|
"authPasswordLength": "Password must be 8-64 characters",
|
||||||
"authPasswordWeak": "Password needs both letters and numbers",
|
"authPasswordWeak": "Password needs both letters and numbers",
|
||||||
"authNetworkError": "Network error, please try again",
|
"authNetworkError": "Network error, please try again",
|
||||||
|
"authLoginFailed": "Login failed",
|
||||||
|
"authRegisterFailed": "Registration failed",
|
||||||
|
"authErrorConfirm": "OK",
|
||||||
"authSwitchToSignUp": "New here? Create an account",
|
"authSwitchToSignUp": "New here? Create an account",
|
||||||
"authSwitchToSignIn": "Already have an account? Sign in",
|
"authSwitchToSignIn": "Already have an account? Sign in",
|
||||||
"authGateTitle": "Sign in to continue",
|
"authGateTitle": "Sign in to continue",
|
||||||
|
|||||||
@@ -409,6 +409,9 @@
|
|||||||
"authPasswordLength": "Kata sandi harus 8-64 karakter",
|
"authPasswordLength": "Kata sandi harus 8-64 karakter",
|
||||||
"authPasswordWeak": "Kata sandi perlu huruf dan angka",
|
"authPasswordWeak": "Kata sandi perlu huruf dan angka",
|
||||||
"authNetworkError": "Jaringan bermasalah, coba lagi",
|
"authNetworkError": "Jaringan bermasalah, coba lagi",
|
||||||
|
"authLoginFailed": "Gagal masuk",
|
||||||
|
"authRegisterFailed": "Pendaftaran gagal",
|
||||||
|
"authErrorConfirm": "Mengerti",
|
||||||
"authSwitchToSignUp": "Baru di sini? Buat akun",
|
"authSwitchToSignUp": "Baru di sini? Buat akun",
|
||||||
"authSwitchToSignIn": "Sudah punya akun? Masuk",
|
"authSwitchToSignIn": "Sudah punya akun? Masuk",
|
||||||
"authGateTitle": "Masuk untuk melanjutkan",
|
"authGateTitle": "Masuk untuk melanjutkan",
|
||||||
|
|||||||
@@ -1888,6 +1888,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Network error, please try again'**
|
/// **'Network error, please try again'**
|
||||||
String get authNetworkError;
|
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.
|
/// No description provided for @authSwitchToSignUp.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -958,6 +958,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get authNetworkError => 'Network error, please try again';
|
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
|
@override
|
||||||
String get authSwitchToSignUp => 'New here? Create an account';
|
String get authSwitchToSignUp => 'New here? Create an account';
|
||||||
|
|
||||||
|
|||||||
@@ -962,6 +962,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get authNetworkError => 'Jaringan bermasalah, coba lagi';
|
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
|
@override
|
||||||
String get authSwitchToSignUp => 'Baru di sini? Buat akun';
|
String get authSwitchToSignUp => 'Baru di sini? Buat akun';
|
||||||
|
|
||||||
|
|||||||
@@ -946,6 +946,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get authNetworkError => '网络异常,请稍后重试';
|
String get authNetworkError => '网络异常,请稍后重试';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authLoginFailed => '登录失败';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authRegisterFailed => '注册失败';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get authErrorConfirm => '知道了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get authSwitchToSignUp => '还没有账号?去注册';
|
String get authSwitchToSignUp => '还没有账号?去注册';
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,9 @@
|
|||||||
"authPasswordLength": "密码长度需在 8-64 位之间",
|
"authPasswordLength": "密码长度需在 8-64 位之间",
|
||||||
"authPasswordWeak": "密码需同时包含字母和数字",
|
"authPasswordWeak": "密码需同时包含字母和数字",
|
||||||
"authNetworkError": "网络异常,请稍后重试",
|
"authNetworkError": "网络异常,请稍后重试",
|
||||||
|
"authLoginFailed": "登录失败",
|
||||||
|
"authRegisterFailed": "注册失败",
|
||||||
|
"authErrorConfirm": "知道了",
|
||||||
"authSwitchToSignUp": "还没有账号?去注册",
|
"authSwitchToSignUp": "还没有账号?去注册",
|
||||||
"authSwitchToSignIn": "已有账号?去登录",
|
"authSwitchToSignIn": "已有账号?去登录",
|
||||||
"authGateTitle": "登录后可继续",
|
"authGateTitle": "登录后可继续",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../core/platform/keyboard_inset.dart';
|
|||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/widgets/centered_error_dialog.dart';
|
||||||
import '../../core/widgets/subpage_header.dart';
|
import '../../core/widgets/subpage_header.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import 'auth_logic.dart';
|
import 'auth_logic.dart';
|
||||||
@@ -227,6 +228,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
_logic.errorMessage = null;
|
_logic.errorMessage = null;
|
||||||
});
|
});
|
||||||
final auth = context.read<AuthController>();
|
final auth = context.read<AuthController>();
|
||||||
|
String? failureMessage;
|
||||||
try {
|
try {
|
||||||
if (_logic.signUpMode) {
|
if (_logic.signUpMode) {
|
||||||
await auth.register(
|
await auth.register(
|
||||||
@@ -242,14 +244,18 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).maybePop();
|
Navigator.of(context).maybePop();
|
||||||
} on ApiException catch (error) {
|
} on ApiException catch (error) {
|
||||||
if (!mounted) return;
|
failureMessage = error.message;
|
||||||
setState(() => _logic.errorMessage = error.message);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
failureMessage = l10n.authNetworkError;
|
||||||
setState(() => _logic.errorMessage = l10n.authNetworkError);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _logic.busy = false);
|
if (mounted) setState(() => _logic.busy = false);
|
||||||
}
|
}
|
||||||
|
if (!mounted || failureMessage == null) return;
|
||||||
|
await showCenteredErrorDialog(
|
||||||
|
context,
|
||||||
|
title: _logic.signUpMode ? l10n.authRegisterFailed : l10n.authLoginFailed,
|
||||||
|
message: failureMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart';
|
|||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.dart';
|
||||||
import '../../core/platform/keyboard_inset.dart';
|
import '../../core/platform/keyboard_inset.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/widgets/centered_error_dialog.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
const double _fieldHeight = 52;
|
const double _fieldHeight = 52;
|
||||||
@@ -164,6 +165,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
});
|
});
|
||||||
final haptics = context.read<Haptics>();
|
final haptics = context.read<Haptics>();
|
||||||
final auth = context.read<AuthController>();
|
final auth = context.read<AuthController>();
|
||||||
|
String? failureMessage;
|
||||||
await haptics.lightImpact();
|
await haptics.lightImpact();
|
||||||
try {
|
try {
|
||||||
await auth.sendOtp(
|
await auth.sendOtp(
|
||||||
@@ -173,16 +175,20 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_startCountdown();
|
_startCountdown();
|
||||||
setState(() => _error = null);
|
setState(() => _error = null);
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(
|
||||||
.showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
||||||
_codeFocus.requestFocus();
|
_codeFocus.requestFocus();
|
||||||
} on ApiException catch (error) {
|
} on ApiException catch (error) {
|
||||||
if (mounted) setState(() => _error = error.message);
|
failureMessage = error.message;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _error = l10n.authNetworkError);
|
failureMessage = l10n.authNetworkError;
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
|
if (mounted && failureMessage != null) {
|
||||||
|
await _showFailure(failureMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startCountdown() {
|
void _startCountdown() {
|
||||||
@@ -212,6 +218,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
});
|
});
|
||||||
final haptics = context.read<Haptics>();
|
final haptics = context.read<Haptics>();
|
||||||
final auth = context.read<AuthController>();
|
final auth = context.read<AuthController>();
|
||||||
|
String? failureMessage;
|
||||||
await haptics.lightImpact();
|
await haptics.lightImpact();
|
||||||
try {
|
try {
|
||||||
if (_signUp) {
|
if (_signUp) {
|
||||||
@@ -221,12 +228,24 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop(true);
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
} on ApiException catch (error) {
|
} on ApiException catch (error) {
|
||||||
if (mounted) setState(() => _error = error.message);
|
failureMessage = error.message;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _error = l10n.authNetworkError);
|
failureMessage = l10n.authNetworkError;
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _submitting = false);
|
if (mounted) setState(() => _submitting = false);
|
||||||
}
|
}
|
||||||
|
if (mounted && failureMessage != null) {
|
||||||
|
await _showFailure(failureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showFailure(String message) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return showCenteredErrorDialog(
|
||||||
|
context,
|
||||||
|
title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _switchMode() {
|
void _switchMode() {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Enter your email'), findsOneWidget);
|
expect(find.text('Enter your email'), findsOneWidget);
|
||||||
expect(find.text('Welcome back'), findsOneWidget);
|
expect(find.text('Welcome back'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('centered-error-dialog')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -94,6 +95,80 @@ void main() {
|
|||||||
find.text('Password needs both letters and numbers'),
|
find.text('Password needs both letters and numbers'),
|
||||||
findsOneWidget,
|
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<FilledButton>(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<void> _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) {
|
void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) {
|
||||||
final screenHeight =
|
final screenHeight =
|
||||||
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
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<AuthSession> 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<AuthSession> register({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String deviceId,
|
||||||
|
String platform = '',
|
||||||
|
String appVersion = '',
|
||||||
|
}) async {
|
||||||
|
registerCalls++;
|
||||||
|
throw ApiException(409, registerMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _PendingAuthApi extends AuthApi {
|
class _PendingAuthApi extends AuthApi {
|
||||||
_PendingAuthApi()
|
_PendingAuthApi()
|
||||||
: super(
|
: super(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/api_client.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||||
import 'package:fruitshorts/core/data/services/auth_controller.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(find.text('Enter a valid phone number'), findsOneWidget);
|
||||||
expect(adapter.requests, isEmpty);
|
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<TextButton>(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<FilledButton>(find.byKey(const Key('auth-phone-submit')))
|
||||||
|
.onPressed,
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_keep_code_above_ios_keyboard', (tester) async {
|
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<void> sendOtp({
|
||||||
|
required String target,
|
||||||
|
required String scene,
|
||||||
|
required String deviceId,
|
||||||
|
}) async {
|
||||||
|
sendOtpCalls++;
|
||||||
|
throw ApiException(429, sendOtpMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthSession> loginWithPhone({
|
||||||
|
required String target,
|
||||||
|
required String code,
|
||||||
|
required String deviceId,
|
||||||
|
String platform = '',
|
||||||
|
String appVersion = '',
|
||||||
|
}) async {
|
||||||
|
loginWithPhoneCalls++;
|
||||||
|
throw ApiException(401, loginMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pumpHost(WidgetTester tester, AuthController auth) {
|
Future<void> _pumpHost(WidgetTester tester, AuthController auth) {
|
||||||
return tester.pumpWidget(
|
return tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
|
|||||||
Reference in new issue
Block a user