diff --git a/apps/mobile/lib/pages/share_qr_dialog.dart b/apps/mobile/lib/pages/share_qr_dialog.dart index d18061f..7c8dfd8 100644 --- a/apps/mobile/lib/pages/share_qr_dialog.dart +++ b/apps/mobile/lib/pages/share_qr_dialog.dart @@ -8,6 +8,7 @@ /// 相册未公开时琥珀提示明确「该链接目前无法公开浏览」;URL 文本可长按复制。 library; +import 'dart:async'; import 'dart:ui' as ui; import 'package:dio/dio.dart'; @@ -338,10 +339,13 @@ class _PosterSheet extends ConsumerStatefulWidget { ConsumerState<_PosterSheet> createState() => _PosterSheetState(); } +/// 保存状态机:idle → saving →(success 自动复原 / error 留置可重试)。 +/// 反馈一律在弹层内呈现——模态弹层遮挡页面 SnackBar,页面级提示不可见。 +enum _SaveState { idle, saving, success, error } + class _PosterSheetState extends ConsumerState<_PosterSheet> { final GlobalKey _posterKey = GlobalKey(); String? _bannerUrl; - bool _saving = false; /// 海报语言('zh'|'en';打开时刻快照 App 语言前缀,切换只影响海报)。 late String _posterLang = @@ -349,6 +353,15 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> { ? 'zh' : 'en'; + _SaveState _saveState = _SaveState.idle; + Timer? _saveResetTimer; + + @override + void dispose() { + _saveResetTimer?.cancel(); + super.dispose(); + } + @override void initState() { super.initState(); @@ -366,20 +379,23 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> { } Future _save() async { - setState(() => _saving = true); - final messenger = ScaffoldMessenger.of(context); - final l10n = AppLocalizations.of(context); + _saveResetTimer?.cancel(); + setState(() => _saveState = _SaveState.saving); try { final boundary = _posterKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 3); await ref.read(posterSaverProvider)(image); - messenger.showSnackBar(SnackBar(content: Text(l10n.posterSaved))); + if (!mounted) return; + // 成功:按钮翻成功态,2.5s 后自动复原(期间可切语言再存一张)。 + setState(() => _saveState = _SaveState.success); + _saveResetTimer = Timer(const Duration(milliseconds: 2500), () { + if (mounted) setState(() => _saveState = _SaveState.idle); + }); } on Object { - messenger.showSnackBar(SnackBar(content: Text(l10n.posterSaveFailed))); - } finally { - if (mounted) setState(() => _saving = false); + // 失败:弹层内红字留置、按钮复位可重试(对齐隐藏照片弹层模式)。 + if (mounted) setState(() => _saveState = _SaveState.error); } } @@ -563,18 +579,47 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> { ), ), const SizedBox(height: kSpace16), - // ── 下载海报图 ── + // ── 下载海报图:成功=按钮翻成功态自动复原;失败=下行红字可重试 ── FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + onPressed: _saveState == _SaveState.saving ? null : _save, + style: _saveState == _SaveState.success + ? FilledButton.styleFrom( + backgroundColor: + theme.brightness == Brightness.light + ? kColorSuccess + : kColorSuccessDark, ) - : const Icon(Icons.download_outlined, size: 18), - label: Text(l10n.posterDownload), + : null, + icon: switch (_saveState) { + _SaveState.saving => const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + _SaveState.success => const Icon( + Icons.check_circle_outline, + size: 18, + ), + _ => const Icon(Icons.download_outlined, size: 18), + }, + label: Text( + _saveState == _SaveState.success + ? l10n.posterSaved + : l10n.posterDownload, + ), ), + if (_saveState == _SaveState.error) ...[ + const SizedBox(height: kSpace8), + Text( + l10n.posterSaveFailed, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.brightness == Brightness.light + ? kColorError + : kColorErrorDark, + ), + ), + ], ], ), ), diff --git a/apps/mobile/test/pages/share_qr_page_test.dart b/apps/mobile/test/pages/share_qr_page_test.dart index 58cfb8a..c6ce82e 100644 --- a/apps/mobile/test/pages/share_qr_page_test.dart +++ b/apps/mobile/test/pages/share_qr_page_test.dart @@ -5,7 +5,9 @@ /// 主操作写剪贴板 + 已复制提示、Open in browser 次操作经注入替身启动器 /// 打开并退出页面、未公开相册显示「该链接目前无法公开浏览」琥珀提示 /// (公开相册不显示)、海报弹层内「中文/English」切换(默认跟 App 语言, -/// 提示文案/相册标题/二维码 lang 随切换,弹层 chrome 仍跟 App 语言)。 +/// 提示文案/相册标题/二维码 lang 随切换,弹层 chrome 仍跟 App 语言)、 +/// 下载海报反馈在弹层内可见(成功=按钮翻成功态自动复原;失败=弹层内 +/// 红字可重试;SnackBar 在模态弹层下不可见,不得作为反馈通道)。 library; import 'package:flutter/material.dart'; @@ -26,12 +28,15 @@ void main() { WidgetTester tester, { bool publicEnabled = true, UrlLauncher? launcher, + PosterSaver? saver, }) async { await tester.pumpWidget( ProviderScope( overrides: [ if (launcher != null) urlLauncherProvider.overrideWith((ref) => launcher), + if (saver != null) + posterSaverProvider.overrideWith((ref) => saver), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -121,6 +126,72 @@ void main() { expect(find.text('Download poster image'), findsOneWidget); }); + testWidgets('下载海报成功:按钮翻成功态并自动复原,全程无 SnackBar', ( + tester, + ) async { + await pumpPage(tester, saver: (_) async {}); + await tester.tap(find.text('Generate QR poster')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Download poster image')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Download poster image')); + await tester.pumpAndSettle(); + + // 反馈必须在弹层内可见(SnackBar 被模态弹层遮挡,禁用)。 + expect(find.byType(SnackBar), findsNothing); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Poster saved to gallery'), + ), + findsOneWidget, + reason: '成功反馈显示在下载按钮上(弹层内)', + ); + + // 约 2.5s 后自动复原,可再次下载(配合语言切换存第二张)。 + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(find.text('Poster saved to gallery'), findsNothing); + expect(find.text('Download poster image'), findsOneWidget); + }); + + testWidgets('下载海报失败:弹层内红字提示且可重试', (tester) async { + var calls = 0; + await pumpPage( + tester, + saver: (_) async { + calls++; + throw StateError('save failed'); + }, + ); + await tester.tap(find.text('Generate QR poster')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Download poster image')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Download poster image')); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsNothing); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Failed to save poster'), + ), + findsOneWidget, + reason: '失败反馈在弹层内(对齐隐藏照片弹层的面板内提示模式)', + ); + // 按钮复位可重试。 + expect(find.text('Download poster image'), findsOneWidget); + + await tester.ensureVisible(find.text('Download poster image')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Download poster image')); + await tester.pumpAndSettle(); + expect(calls, 2, reason: '失败后可重试'); + }); + testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示', (tester) async { final clipboardCalls = [];