merge: 合并登录异常居中吐司

This commit is contained in:
Zhou Weidong committed 2026-10-22 09:58:08 +08:00
commit 7b068e8eb4
16 files changed
+341 -175

No files matched your search

@@ -0,0 +1,29 @@
# 登录异常居中吐司
## 目标
- 将上一版认证异常 AlertDialog 替换为无交互的屏幕居中吐司。
- 吐司淡入展示约 2 秒后自动淡出,不遮罩、不拦截手势。
- 同一时刻只存在一个吐司,新消息替换旧消息。
## 实施步骤
1. 删除 `centered_error_dialog.dart`,新增 `centered_toast.dart`。
2. 认证页和手机号弹窗异常路径改用 `showCenteredToast`,保留 busy 复位和成功 SnackBar。
3. 清理不再使用的认证错误标题与确认按钮 l10n 键,重新生成 AppLocalizations。
4. 更新认证测试为吐司断言,并补单例替换、自动消失测试。
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 吐司使用 root Overlay,认证路由退出时仍会按自身计时器自动清理。
- 测试必须 pump 超过展示和淡出时长,确保不遗留 Timer 或 OverlayEntry。
## 执行结果
- 已删除 AlertDialog 组件,新增 OverlayEntry 居中吐司。
- 邮箱和手机号异常改用吐司;busy 复位、成功 SnackBar 和本地内联校验保持不变。
- 已清理三个不再使用的 l10n 键并重新生成 AppLocalizations。
- 覆盖单例替换、点击穿透、自动消失、接口异常和本地校验不弹吐司。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 573 项全过。
- 未启停服务,未操作真机。
+33
View File
@@ -233,3 +233,36 @@ $ flutter test --no-pub
解读:登录异常、注册标题、未知异常回退、本地校验不弹窗、遮罩/按钮关闭和手机号两条
异常路径均通过;全量静态分析零告警,570 项测试全过。`pubspec.lock` 已还原,
未启停服务,未操作真机。
## 2026-10-22 登录异常调整为中心吐司
- 已从 `develop`(`11e37a9`)创建 `feature/login-error-center-toast`。
- 删除 AlertDialog 组件,新增 OverlayEntry 居中吐司:淡入淡出、约 2 秒消失、不拦截手势。
- 吐司保持全局单例,新消息替换旧消息;邮箱与手机号异常路径均已切换。
- 本地表单校验继续内联,手机号验证码发送成功 SnackBar 保持不变。
- 清理 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 并重新生成 l10n。
执行命令与原始输出:
```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/core/centered_toast_test.dart
00:00 +3: All tests passed!
$ 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
01:00 +573: All tests passed!
```
解读:吐司单例替换、点击穿透和自动消失通过;认证异常与本地校验路径通过;
全量静态分析零告警,573 项测试全过。`pubspec.lock` 已还原,未启停服务,
未操作真机。
@@ -1,67 +0,0 @@
// 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),
),
],
),
);
}
+128
View File
@@ -0,0 +1,128 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
const Key _toastKey = Key('centered-toast');
const Duration _fadeDuration = Duration(milliseconds: 200);
const Duration _visibleDuration = Duration(seconds: 2);
const double _toastRadius = 12;
const double _toastMaxWidth = 320;
const double _toastHorizontalInset = 20;
const double _toastVerticalInset = 12;
const double _toastBackgroundOpacity = 0.78;
const TextStyle _toastTextStyle = TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w500,
);
const EdgeInsets _toastPadding = EdgeInsets.symmetric(
horizontal: _toastHorizontalInset,
vertical: _toastVerticalInset,
);
OverlayEntry? _activeToast;
/// Shows a lightweight centered message and replaces any active toast.
void showCenteredToast(BuildContext context, String message) {
_removeActiveToast();
final overlay = Overlay.of(context, rootOverlay: true);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _CenteredToast(
message: message,
onDismissed: () {
if (!identical(_activeToast, entry)) return;
_activeToast = null;
if (entry.mounted) entry.remove();
},
),
);
_activeToast = entry;
overlay.insert(entry);
}
void _removeActiveToast() {
final entry = _activeToast;
_activeToast = null;
if (entry != null && entry.mounted) entry.remove();
}
class _CenteredToast extends StatefulWidget {
const _CenteredToast({required this.message, required this.onDismissed});
final String message;
final VoidCallback onDismissed;
@override
State<_CenteredToast> createState() => _CenteredToastState();
}
class _CenteredToastState extends State<_CenteredToast>
with SingleTickerProviderStateMixin {
late final AnimationController _opacity = AnimationController(
vsync: this,
duration: _fadeDuration,
);
Timer? _dismissTimer;
@override
void initState() {
super.initState();
_opacity.forward();
_dismissTimer = Timer(_visibleDuration, _dismiss);
}
Future<void> _dismiss() async {
try {
await _opacity.reverse();
} on TickerCanceled {
return;
}
if (mounted) widget.onDismissed();
}
@override
void dispose() {
_dismissTimer?.cancel();
_opacity.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: Center(
child: FadeTransition(
opacity: CurvedAnimation(
parent: _opacity,
curve: Curves.easeOutCubic,
),
child: Material(
type: MaterialType.transparency,
child: Container(
key: _toastKey,
constraints: const BoxConstraints(maxWidth: _toastMaxWidth),
padding: _toastPadding,
decoration: BoxDecoration(
color: Colors.black.withValues(
alpha: _toastBackgroundOpacity,
),
borderRadius: BorderRadius.circular(_toastRadius),
),
child: Text(
widget.message,
textAlign: TextAlign.center,
style: _toastTextStyle,
),
),
),
),
),
),
);
}
}
-3
View File
@@ -435,9 +435,6 @@
"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",
-3
View File
@@ -409,9 +409,6 @@
"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",
-18
View File
@@ -1888,24 +1888,6 @@ 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:
-9
View File
@@ -958,15 +958,6 @@ 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';
-9
View File
@@ -962,15 +962,6 @@ 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';
-9
View File
@@ -946,15 +946,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get authNetworkError => '网络异常,请稍后重试';
@override
String get authLoginFailed => '登录失败';
@override
String get authRegisterFailed => '注册失败';
@override
String get authErrorConfirm => '知道了';
@override
String get authSwitchToSignUp => '还没有账号?去注册';
-3
View File
@@ -371,9 +371,6 @@
"authPasswordLength": "密码长度需在 8-64 位之间",
"authPasswordWeak": "密码需同时包含字母和数字",
"authNetworkError": "网络异常,请稍后重试",
"authLoginFailed": "登录失败",
"authRegisterFailed": "注册失败",
"authErrorConfirm": "知道了",
"authSwitchToSignUp": "还没有账号?去注册",
"authSwitchToSignIn": "已有账号?去登录",
"authGateTitle": "登录后可继续",
+2 -6
View File
@@ -12,7 +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/centered_toast.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import 'auth_logic.dart';
@@ -251,11 +251,7 @@ class _AuthPageState extends State<AuthPage> {
if (mounted) setState(() => _logic.busy = false);
}
if (!mounted || failureMessage == null) return;
await showCenteredErrorDialog(
context,
title: _logic.signUpMode ? l10n.authRegisterFailed : l10n.authLoginFailed,
message: failureMessage,
);
showCenteredToast(context, failureMessage);
}
@override
+3 -12
View File
@@ -11,7 +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 '../../core/widgets/centered_toast.dart';
import '../../l10n/app_localizations.dart';
const double _fieldHeight = 52;
@@ -187,7 +187,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
if (mounted) setState(() => _sending = false);
}
if (mounted && failureMessage != null) {
await _showFailure(failureMessage);
showCenteredToast(context, failureMessage);
}
}
@@ -235,19 +235,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
if (mounted) setState(() => _submitting = false);
}
if (mounted && failureMessage != null) {
await _showFailure(failureMessage);
showCenteredToast(context, failureMessage);
}
}
Future<void> _showFailure(String message) {
final l10n = AppLocalizations.of(context)!;
return showCenteredErrorDialog(
context,
title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed,
message: message,
);
}
void _switchMode() {
setState(() {
_signUp = !_signUp;
+35 -29
View File
@@ -78,7 +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);
expect(find.byKey(const Key('centered-toast')), findsNothing);
});
testWidgets(
@@ -95,11 +95,11 @@ void main() {
find.text('Password needs both letters and numbers'),
findsOneWidget,
);
expect(find.byKey(const Key('centered-error-dialog')), findsNothing);
expect(find.byKey(const Key('centered-toast')), findsNothing);
},
);
testWidgets('should_show_centered_dialog_when_login_api_returns_an_error', (
testWidgets('should_show_centered_toast_when_login_api_returns_an_error', (
tester,
) async {
final api = _FailingAuthApi(loginMessage: 'Invalid email or password');
@@ -110,13 +110,13 @@ void main() {
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);
final toast = find.byKey(const Key('centered-toast'));
expect(toast, findsOneWidget);
expect(find.text('Invalid email or password'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
expect(api.loginCalls, 1);
expect(
tester.getCenter(dialog),
tester.getCenter(toast),
offsetMoreOrLessEquals(
tester.getCenter(find.byType(Scaffold)),
epsilon: 1,
@@ -129,32 +129,29 @@ void main() {
isNotNull,
);
await tester.tap(find.byKey(const Key('centered-error-confirm')));
await tester.pumpAndSettle();
expect(dialog, findsNothing);
await _pumpToastAway(tester);
expect(toast, 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);
testWidgets('should_show_centered_toast_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();
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);
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
expect(find.text('Email already exists'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
expect(api.registerCalls, 1);
await tester.tapAt(const Offset(8, 8));
await tester.pumpAndSettle();
expect(find.byKey(const Key('centered-error-dialog')), findsNothing);
},
);
await _pumpToastAway(tester);
expect(find.byKey(const Key('centered-toast')), findsNothing);
});
testWidgets(
'should_show_network_error_when_login_throws_an_unknown_exception',
@@ -167,8 +164,12 @@ void main() {
await tester.tap(find.byKey(const Key('auth-submit')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget);
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
expect(find.text('Network error, please try again'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await _pumpToastAway(tester);
expect(find.byKey(const Key('centered-toast')), findsNothing);
},
);
@@ -422,6 +423,11 @@ Future<void> _enterValidCredentials(WidgetTester tester) async {
await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234');
}
Future<void> _pumpToastAway(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
}
void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) {
final screenHeight =
tester.view.physicalSize.height / tester.view.devicePixelRatio;
+18 -7
View File
@@ -98,10 +98,10 @@ void main() {
expect(find.text('Enter a valid phone number'), findsOneWidget);
expect(adapter.requests, isEmpty);
expect(find.byKey(const Key('centered-error-dialog')), findsNothing);
expect(find.byKey(const Key('centered-toast')), findsNothing);
});
testWidgets('should_show_centered_dialog_when_otp_request_fails', (
testWidgets('should_show_centered_toast_when_otp_request_fails', (
tester,
) async {
final api = _FailingPhoneAuthApi(sendOtpMessage: 'Too many OTP requests');
@@ -117,9 +117,9 @@ void main() {
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.byKey(const Key('centered-toast')), findsOneWidget);
expect(find.text('Too many OTP requests'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
expect(api.sendOtpCalls, 1);
expect(
tester
@@ -127,9 +127,12 @@ void main() {
.onPressed,
isNotNull,
);
await _pumpToastAway(tester);
expect(find.byKey(const Key('centered-toast')), findsNothing);
});
testWidgets('should_show_centered_dialog_when_phone_login_fails', (
testWidgets('should_show_centered_toast_when_phone_login_fails', (
tester,
) async {
final api = _FailingPhoneAuthApi(loginMessage: 'Invalid verification code');
@@ -146,9 +149,9 @@ void main() {
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.byKey(const Key('centered-toast')), findsOneWidget);
expect(find.text('Invalid verification code'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
expect(api.loginWithPhoneCalls, 1);
expect(
tester
@@ -156,6 +159,9 @@ void main() {
.onPressed,
isNotNull,
);
await _pumpToastAway(tester);
expect(find.byKey(const Key('centered-toast')), findsNothing);
});
testWidgets('should_keep_code_above_ios_keyboard', (tester) async {
@@ -191,6 +197,11 @@ void main() {
});
}
Future<void> _pumpToastAway(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
}
class _FailingPhoneAuthApi extends AuthApi {
_FailingPhoneAuthApi({
this.sendOtpMessage = 'otp failed',
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/widgets/centered_toast.dart';
void main() {
testWidgets('should_replace_previous_toast_when_shown_again', (tester) async {
late BuildContext hostContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
hostContext = context;
return const Scaffold(body: SizedBox.expand());
},
),
),
);
showCenteredToast(hostContext, 'first message');
await tester.pump();
showCenteredToast(hostContext, 'second message');
await tester.pump();
expect(find.byKey(const Key('centered-toast')), findsOneWidget);
expect(find.text('second message'), findsOneWidget);
expect(find.text('first message'), findsNothing);
await tester.pump(const Duration(seconds: 2));
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const Key('centered-toast')), findsNothing);
});
testWidgets('should_allow_underlying_gesture_when_toast_is_visible', (
tester,
) async {
late BuildContext hostContext;
var taps = 0;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
hostContext = context;
return Scaffold(
body: GestureDetector(
key: const Key('toast-host'),
behavior: HitTestBehavior.opaque,
onTap: () => taps++,
child: const SizedBox.expand(),
),
);
},
),
),
);
showCenteredToast(hostContext, 'tap-through');
await tester.pump();
await tester.tapAt(
tester.getCenter(find.byKey(const Key('centered-toast'))),
);
expect(taps, 1);
await tester.pump(const Duration(seconds: 2));
await tester.pump(const Duration(milliseconds: 200));
});
testWidgets('should_replace_toast_when_previous_toast_is_fading_out', (
tester,
) async {
late BuildContext hostContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
hostContext = context;
return const Scaffold(body: SizedBox.expand());
},
),
),
);
showCenteredToast(hostContext, 'first message');
await tester.pump();
await tester.pump(const Duration(seconds: 2));
showCenteredToast(hostContext, 'replacement message');
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('replacement message'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
});
}