fix(app): 海报下载反馈改弹层内呈现——SnackBar 被模态弹层遮挡不可见
根因:保存成功/失败的 SnackBar 弹在弹层底下的页面 Scaffold 上,被模态 bottom sheet + 遮罩整层盖住,用户永远看不到(保存本身大概率成功)。 - 成功:下载按钮翻成功态(✓+已保存到相册,kColorSuccess)2.5s 自动复原 - 失败:按钮下方弹层内红字留置可重试(对齐隐藏照片弹层面板内提示模式) - 删除被遮挡的 SnackBar 路径;复原 Timer 于 dispose 取消 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
/// 相册未公开时琥珀提示明确「该链接目前无法公开浏览」;URL 文本可长按复制。
|
/// 相册未公开时琥珀提示明确「该链接目前无法公开浏览」;URL 文本可长按复制。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -338,10 +339,13 @@ class _PosterSheet extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<_PosterSheet> createState() => _PosterSheetState();
|
ConsumerState<_PosterSheet> createState() => _PosterSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 保存状态机:idle → saving →(success 自动复原 / error 留置可重试)。
|
||||||
|
/// 反馈一律在弹层内呈现——模态弹层遮挡页面 SnackBar,页面级提示不可见。
|
||||||
|
enum _SaveState { idle, saving, success, error }
|
||||||
|
|
||||||
class _PosterSheetState extends ConsumerState<_PosterSheet> {
|
class _PosterSheetState extends ConsumerState<_PosterSheet> {
|
||||||
final GlobalKey _posterKey = GlobalKey();
|
final GlobalKey _posterKey = GlobalKey();
|
||||||
String? _bannerUrl;
|
String? _bannerUrl;
|
||||||
bool _saving = false;
|
|
||||||
|
|
||||||
/// 海报语言('zh'|'en';打开时刻快照 App 语言前缀,切换只影响海报)。
|
/// 海报语言('zh'|'en';打开时刻快照 App 语言前缀,切换只影响海报)。
|
||||||
late String _posterLang =
|
late String _posterLang =
|
||||||
@@ -349,6 +353,15 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
|
|||||||
? 'zh'
|
? 'zh'
|
||||||
: 'en';
|
: 'en';
|
||||||
|
|
||||||
|
_SaveState _saveState = _SaveState.idle;
|
||||||
|
Timer? _saveResetTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveResetTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -366,20 +379,23 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() => _saving = true);
|
_saveResetTimer?.cancel();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
setState(() => _saveState = _SaveState.saving);
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
try {
|
try {
|
||||||
final boundary =
|
final boundary =
|
||||||
_posterKey.currentContext!.findRenderObject()
|
_posterKey.currentContext!.findRenderObject()
|
||||||
as RenderRepaintBoundary;
|
as RenderRepaintBoundary;
|
||||||
final image = await boundary.toImage(pixelRatio: 3);
|
final image = await boundary.toImage(pixelRatio: 3);
|
||||||
await ref.read(posterSaverProvider)(image);
|
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 {
|
} on Object {
|
||||||
messenger.showSnackBar(SnackBar(content: Text(l10n.posterSaveFailed)));
|
// 失败:弹层内红字留置、按钮复位可重试(对齐隐藏照片弹层模式)。
|
||||||
} finally {
|
if (mounted) setState(() => _saveState = _SaveState.error);
|
||||||
if (mounted) setState(() => _saving = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,18 +579,47 @@ class _PosterSheetState extends ConsumerState<_PosterSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: kSpace16),
|
const SizedBox(height: kSpace16),
|
||||||
// ── 下载海报图 ──
|
// ── 下载海报图:成功=按钮翻成功态自动复原;失败=下行红字可重试 ──
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: _saveState == _SaveState.saving ? null : _save,
|
||||||
icon: _saving
|
style: _saveState == _SaveState.success
|
||||||
? const SizedBox(
|
? FilledButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
theme.brightness == Brightness.light
|
||||||
|
? kColorSuccess
|
||||||
|
: kColorSuccessDark,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
icon: switch (_saveState) {
|
||||||
|
_SaveState.saving => const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
|
||||||
: const Icon(Icons.download_outlined, size: 18),
|
|
||||||
label: Text(l10n.posterDownload),
|
|
||||||
),
|
),
|
||||||
|
_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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
/// 主操作写剪贴板 + 已复制提示、Open in browser 次操作经注入替身启动器
|
/// 主操作写剪贴板 + 已复制提示、Open in browser 次操作经注入替身启动器
|
||||||
/// 打开并退出页面、未公开相册显示「该链接目前无法公开浏览」琥珀提示
|
/// 打开并退出页面、未公开相册显示「该链接目前无法公开浏览」琥珀提示
|
||||||
/// (公开相册不显示)、海报弹层内「中文/English」切换(默认跟 App 语言,
|
/// (公开相册不显示)、海报弹层内「中文/English」切换(默认跟 App 语言,
|
||||||
/// 提示文案/相册标题/二维码 lang 随切换,弹层 chrome 仍跟 App 语言)。
|
/// 提示文案/相册标题/二维码 lang 随切换,弹层 chrome 仍跟 App 语言)、
|
||||||
|
/// 下载海报反馈在弹层内可见(成功=按钮翻成功态自动复原;失败=弹层内
|
||||||
|
/// 红字可重试;SnackBar 在模态弹层下不可见,不得作为反馈通道)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -26,12 +28,15 @@ void main() {
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
bool publicEnabled = true,
|
bool publicEnabled = true,
|
||||||
UrlLauncher? launcher,
|
UrlLauncher? launcher,
|
||||||
|
PosterSaver? saver,
|
||||||
}) async {
|
}) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
if (launcher != null)
|
if (launcher != null)
|
||||||
urlLauncherProvider.overrideWith((ref) => launcher),
|
urlLauncherProvider.overrideWith((ref) => launcher),
|
||||||
|
if (saver != null)
|
||||||
|
posterSaverProvider.overrideWith((ref) => saver),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
@@ -121,6 +126,72 @@ void main() {
|
|||||||
expect(find.text('Download poster image'), findsOneWidget);
|
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 + 已复制提示',
|
testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final clipboardCalls = <MethodCall>[];
|
final clipboardCalls = <MethodCall>[];
|
||||||
|
|||||||
Reference in New Issue
Block a user