diff --git a/docs/plans/2026-10-22-login-error-center-toast.md b/docs/plans/2026-10-22-login-error-center-toast.md new file mode 100644 index 0000000..8897f34 --- /dev/null +++ b/docs/plans/2026-10-22-login-error-center-toast.md @@ -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 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index c34a0c5..c3bef1c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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` 已还原,未启停服务, +未操作真机。 diff --git a/lib/core/widgets/centered_error_dialog.dart b/lib/core/widgets/centered_error_dialog.dart deleted file mode 100644 index 5a36621..0000000 --- a/lib/core/widgets/centered_error_dialog.dart +++ /dev/null @@ -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 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/core/widgets/centered_toast.dart b/lib/core/widgets/centered_toast.dart new file mode 100644 index 0000000..709be8a --- /dev/null +++ b/lib/core/widgets/centered_toast.dart @@ -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 _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, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 528a3bf..526f378 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index f2995ce..b707d25 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 914bbf9..447651c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 95459ce..d36f78e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f048f14..b5c5ee0 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 47411c3..3bfa8c4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '还没有账号?去注册'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 805b5b8..32ecfa9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -371,9 +371,6 @@ "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 dfd01a9..9d5106a 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -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 { 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 diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 7702249..0355821 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -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 { if (mounted) setState(() => _sending = false); } if (mounted && failureMessage != null) { - await _showFailure(failureMessage); + showCenteredToast(context, failureMessage); } } @@ -235,19 +235,10 @@ class _PhoneAuthSheetState extends State { if (mounted) setState(() => _submitting = false); } if (mounted && failureMessage != null) { - await _showFailure(failureMessage); + showCenteredToast(context, failureMessage); } } - Future _showFailure(String message) { - final l10n = AppLocalizations.of(context)!; - return showCenteredErrorDialog( - context, - title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed, - message: message, - ); - } - void _switchMode() { setState(() { _signUp = !_signUp; diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 923f450..85a005b 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -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 _enterValidCredentials(WidgetTester tester) async { await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); } +Future _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; diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 3c3e2d8..6b08bab 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -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 _pumpToastAway(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); +} + class _FailingPhoneAuthApi extends AuthApi { _FailingPhoneAuthApi({ this.sendOtpMessage = 'otp failed', diff --git a/test/core/centered_toast_test.dart b/test/core/centered_toast_test.dart new file mode 100644 index 0000000..84e4a9a --- /dev/null +++ b/test/core/centered_toast_test.dart @@ -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(); + }); +}