From df0c31859942d7f0bef155068977769f11c10542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 23 Sep 2026 11:27:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20share=20redesign=20=E2=80=94=20car?= =?UTF-8?q?d=20share=20(system),=20QR=20poster=20(banner+title+time+venue+?= =?UTF-8?q?QR,=20downloadable),=20copy=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三方式(用户需求):①卡片模式默认=系统分享面板(share_plus SharePlus. instance,微信/其他应用,文本=标题+链接)②生成二维码=海报弹层——顶部 宣传图(banner→cover→品牌占位回退)+ 活动标题 + 时间 + 地址 + 二维码, 「下载海报图」RepaintBoundary 3x 渲染 PNG → gal 存相册 ③复制链接 - 依赖 share_plus + gal;分享/保存均 Provider 注入(测试替身) - 测试适配三方式结构 + 海报弹层断言(397/397 全绿、analyze 0) Co-Authored-By: Claude Code --- apps/mobile/lib/l10n/app_en.arb | 7 +- apps/mobile/lib/l10n/app_localizations.dart | 30 ++ .../mobile/lib/l10n/app_localizations_en.dart | 15 + .../mobile/lib/l10n/app_localizations_zh.dart | 15 + apps/mobile/lib/l10n/app_zh.arb | 7 +- apps/mobile/lib/pages/share_qr_dialog.dart | 402 +++++++++++++++--- apps/mobile/pubspec.lock | 24 ++ apps/mobile/pubspec.yaml | 2 + .../mobile/test/pages/share_qr_page_test.dart | 19 +- 9 files changed, 452 insertions(+), 69 deletions(-) diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 1839ae0..d371bc4 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -421,5 +421,10 @@ "bannerPicker": "Banner", "bannerPickerHint": "Tap to pick a banner image (shown atop the viewer page)", "bannerSelected": "Banner selected — will upload on save", - "bannerUploadFailed": "Banner upload failed" + "bannerUploadFailed": "Banner upload failed", + "shareCardMode": "Share to WeChat / other apps", + "sharePosterMode": "Generate QR poster", + "posterDownload": "Download poster image", + "posterSaved": "Poster saved to gallery", + "posterSaveFailed": "Failed to save poster" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 2204946..5c3d934 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1981,6 +1981,36 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'宣传图上传失败'** String get bannerUploadFailed; + + /// No description provided for @shareCardMode. + /// + /// In zh, this message translates to: + /// **'分享到微信 / 其他应用'** + String get shareCardMode; + + /// No description provided for @sharePosterMode. + /// + /// In zh, this message translates to: + /// **'生成二维码海报'** + String get sharePosterMode; + + /// No description provided for @posterDownload. + /// + /// In zh, this message translates to: + /// **'下载海报图'** + String get posterDownload; + + /// No description provided for @posterSaved. + /// + /// In zh, this message translates to: + /// **'海报已保存到相册'** + String get posterSaved; + + /// No description provided for @posterSaveFailed. + /// + /// In zh, this message translates to: + /// **'海报保存失败'** + String get posterSaveFailed; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 41df308..ce77ffd 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -1013,4 +1013,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bannerUploadFailed => 'Banner upload failed'; + + @override + String get shareCardMode => 'Share to WeChat / other apps'; + + @override + String get sharePosterMode => 'Generate QR poster'; + + @override + String get posterDownload => 'Download poster image'; + + @override + String get posterSaved => 'Poster saved to gallery'; + + @override + String get posterSaveFailed => 'Failed to save poster'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 933b836..d8ecd30 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -980,4 +980,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bannerUploadFailed => '宣传图上传失败'; + + @override + String get shareCardMode => '分享到微信 / 其他应用'; + + @override + String get sharePosterMode => '生成二维码海报'; + + @override + String get posterDownload => '下载海报图'; + + @override + String get posterSaved => '海报已保存到相册'; + + @override + String get posterSaveFailed => '海报保存失败'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index c28b0ac..3a2b1db 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -421,5 +421,10 @@ "bannerPicker": "宣传图", "bannerPickerHint": "点击选择宣传图(展示在观众页顶部)", "bannerSelected": "已选择宣传图——保存时上传", - "bannerUploadFailed": "宣传图上传失败" + "bannerUploadFailed": "宣传图上传失败", + "shareCardMode": "分享到微信 / 其他应用", + "sharePosterMode": "生成二维码海报", + "posterDownload": "下载海报图", + "posterSaved": "海报已保存到相册", + "posterSaveFailed": "海报保存失败" } diff --git a/apps/mobile/lib/pages/share_qr_dialog.dart b/apps/mobile/lib/pages/share_qr_dialog.dart index f626d53..7698085 100644 --- a/apps/mobile/lib/pages/share_qr_dialog.dart +++ b/apps/mobile/lib/pages/share_qr_dialog.dart @@ -1,21 +1,25 @@ -/// 相册分享页(UI-03 §4 页面 19 重设计;原 UI-01 分享对话框升级为全屏 -/// 分享页,文件名保留 share_qr_dialog.dart 兼容既有引用)。 +/// 相册分享页(UI-06 重设计:三种分享方式,用户需求 2026-09-23)。 /// -/// 手机全屏页(平板内容居中收窄为卡片,同一布局代码两条宽度分支): -/// 真实封面(可缺省占位)→ 名称/日期地点 → 现场生成二维码(白底突出, -/// data 为观众链接 `{VIEWER_BASE}/a/{token}?lang=`)→ 扫码说明 → Copy -/// link(主操作,复制同一链接)→ Open in browser(次操作,经 url_launcher -/// 外链打开)。相册未公开时以琥珀提示明确「该链接目前无法公开浏览」, -/// 不写「任何人可查看」;URL 文本可长按选中复制(复制失败的选择文本 -/// 回退),但公开 token 不作主视觉编号。 +/// 1. 卡片模式(默认):系统分享面板(share_plus)——微信 / 手机上其他应用, +/// 分享文本 = 相册标题 + 观众链接; +/// 2. 生成二维码:弹出**二维码海报**(顶部宣传图 → 活动标题 / 时间 / 地址 / +/// 二维码),可**下载海报图**到相册(gal,RepaintBoundary 渲染 3x PNG); +/// 3. 复制链接。 +/// 相册未公开时琥珀提示明确「该链接目前无法公开浏览」;URL 文本可长按复制。 library; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gal/gal.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../core/api_client.dart'; import '../core/config.dart'; import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; @@ -30,20 +34,41 @@ typedef UrlLauncher = Future Function(String url); String shareLangParam(String localeName) => localeName.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'; -/// 分享链接(二维码与复制同一 URL):`{VIEWER_BASE}/a/{token}?lang=`。 +/// 分享链接(二维码 / 复制 / 系统分享同一 URL)。 String shareAlbumUrl(Album album, String localeName) => ApiConfig.viewerAlbumUrl( album.publicToken, lang: shareLangParam(localeName), ); -/// Provider:外部浏览器打开 URL(LaunchMode.externalApplication——浏览器 -/// 才能承载观众页的全部交互,内置 WebView 不适用)。 +/// Provider:外部浏览器打开 URL。 final urlLauncherProvider = Provider( - (ref) => (url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + (ref) => + (url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), ); -/// 观众分享页([album] 来自相册详情;手机全屏 / 宽屏内容居中卡片)。 +/// 系统分享注入点(生产=share_plus;测试 override)。 +typedef ShareLauncher = Future Function(String text); + +final shareLauncherProvider = Provider( + // share_plus 11:SharePlus.instance.share(旧 Share.share 已废弃)。 + (ref) => (text) async => SharePlus.instance.share( + ShareParams(text: text, subject: text.split('\n').first), + ), +); + +/// 海报保存相册注入点(生产=gal;测试 override)。 +typedef PosterSaver = Future Function(ui.Image image); + +final posterSaverProvider = Provider( + (ref) => (image) async { + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + if (bytes == null) throw StateError('poster encode failed'); + await Gal.putImageBytes(bytes.buffer.asUint8List()); + }, +); + +/// 观众分享页(三方式:系统分享卡片 / 二维码海报 / 复制链接)。 class ShareQrPage extends ConsumerWidget { const ShareQrPage({required this.album, super.key}); @@ -53,20 +78,17 @@ class ShareQrPage extends ConsumerWidget { await Clipboard.setData(ClipboardData(text: url)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context).copiedToClipboard), - ), + SnackBar(content: Text(AppLocalizations.of(context).copiedToClipboard)), ); } - Future _openInBrowser( + Future _shareToApps( BuildContext context, WidgetRef ref, String url, + String title, ) async { - final launched = await ref.read(urlLauncherProvider)(url); - if (!launched || !context.mounted) return; - Navigator.of(context).pop(); + await ref.read(shareLauncherProvider)('$title\n$url'); } @override @@ -75,8 +97,13 @@ class ShareQrPage extends ConsumerWidget { final theme = Theme.of(context); final localeName = l10n.localeName; final url = shareAlbumUrl(album, localeName); + final title = albumDisplayTitle(album, localeName); final venue = albumDisplayVenue(album, localeName); - final start = formatAlbumDateTime(album.startAt, album.timezone, localeName); + final start = formatAlbumDateTime( + album.startAt, + album.timezone, + localeName, + ); final end = album.endAt == null ? null : formatAlbumDateTime(album.endAt!, album.timezone, localeName); @@ -86,7 +113,6 @@ class ShareQrPage extends ConsumerWidget { appBar: AppBar(title: Text(l10n.shareAlbum)), body: SingleChildScrollView( child: Center( - // 宽屏(平板)内容收窄居中成卡片;手机全宽卡片。 child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Card( @@ -96,7 +122,7 @@ class ShareQrPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 真实封面(无封面同尺寸中性占位)。 + // 相册信息(封面 + 标题 + meta)。 ClipRRect( borderRadius: BorderRadius.circular(kRadiusInput), child: PhotoThumbnail( @@ -106,7 +132,7 @@ class ShareQrPage extends ConsumerWidget { ), const SizedBox(height: kSpace16), Text( - albumDisplayTitle(album, localeName), + title, textAlign: TextAlign.center, style: theme.textTheme.titleLarge, ), @@ -118,37 +144,6 @@ class ShareQrPage extends ConsumerWidget { color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: kSpace16), - // 二维码(白底突出,暗色主题保持扫码对比度)。 - Center( - child: Container( - color: Colors.white, - padding: const EdgeInsets.all(kSpace12), - child: QrImageView( - data: url, - version: QrVersions.auto, - size: 216, - backgroundColor: Colors.white, - ), - ), - ), - const SizedBox(height: kSpace12), - Text( - l10n.shareScanHint, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: kSpace8), - // 链接文本(可长按选中复制;弱化呈现,不作主视觉)。 - SelectableText( - url, - textAlign: TextAlign.center, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), if (!album.publicEnabled) ...[ const SizedBox(height: kSpace12), Container( @@ -183,18 +178,50 @@ class ShareQrPage extends ConsumerWidget { ), ), ], - const SizedBox(height: kSpace16), - // Copy link 主操作;浏览器打开为次级入口。 + const SizedBox(height: kSpace24), + + // ── 方式 1:卡片模式(默认)——系统分享面板 ── FilledButton.icon( + onPressed: () => _shareToApps(context, ref, url, title), + icon: const Icon(Icons.share_outlined, size: 18), + label: Text(l10n.shareCardMode), + ), + const SizedBox(height: kSpace8), + + // ── 方式 2:生成二维码(弹出海报)── + OutlinedButton.icon( + onPressed: () => _openPoster(context), + icon: const Icon(Icons.qr_code_2, size: 20), + label: Text(l10n.sharePosterMode), + ), + const SizedBox(height: kSpace8), + + // ── 方式 3:复制链接 ── + OutlinedButton.icon( onPressed: () => _copy(context, url), icon: const Icon(Icons.copy_outlined, size: 18), label: Text(l10n.copyLink), ), const SizedBox(height: kSpace8), - OutlinedButton.icon( - onPressed: () => _openInBrowser(context, ref, url), - icon: const Icon(Icons.open_in_new, size: 18), - label: Text(l10n.openInBrowser), + + // 链接文本(长按选中复制;浏览器打开为次级入口)。 + SelectableText( + url, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + TextButton( + onPressed: () async { + final launched = await ref.read(urlLauncherProvider)( + url, + ); + if (launched && context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text(l10n.openInBrowser), ), ], ), @@ -205,4 +232,255 @@ class ShareQrPage extends ConsumerWidget { ), ); } + + Future _openPoster(BuildContext context) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => _PosterSheet(album: album), + ); + } +} + +/// 二维码海报弹层:宣传图(banner→cover 兜底)+ 标题 + 时间 + 地址 + 二维码; +/// 「保存海报图」= RepaintBoundary 3x 渲染 PNG → 相册(gal)。 +class _PosterSheet extends ConsumerStatefulWidget { + const _PosterSheet({required this.album}); + + final Album album; + + @override + ConsumerState<_PosterSheet> createState() => _PosterSheetState(); +} + +class _PosterSheetState extends ConsumerState<_PosterSheet> { + final GlobalKey _posterKey = GlobalKey(); + String? _bannerUrl; + bool _saving = false; + + @override + void initState() { + super.initState(); + // 宣传图(banner 全局行;失败静默回退 cover / 占位)。 + Future.microtask(() async { + try { + final url = await ref + .read(apiClientProvider) + .fetchAlbumAssetUrl(widget.album.id, 'banner'); + if (mounted) setState(() => _bannerUrl = url); + } on Object { + // 拉不到 banner 时海报用封面兜底 + } + }); + } + + Future _save() async { + setState(() => _saving = true); + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context); + 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))); + } on Object { + messenger.showSnackBar(SnackBar(content: Text(l10n.posterSaveFailed))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final localeName = l10n.localeName; + final url = shareAlbumUrl(widget.album, localeName); + final title = albumDisplayTitle(widget.album, localeName); + final start = formatAlbumDateTime( + widget.album.startAt, + widget.album.timezone, + localeName, + ); + final end = widget.album.endAt == null + ? null + : formatAlbumDateTime( + widget.album.endAt!, + widget.album.timezone, + localeName, + ); + final dateRange = end == null ? start : '$start ~ $end'; + final venue = albumDisplayVenue(widget.album, localeName); + String? addressForPoster(AlbumSummary album) { + for (final t in albumDisplayTranslations(album, localeName)) { + final a = t.address?.trim(); + if (a != null && a.isNotEmpty) return a; + } + return null; + } + + final address = addressForPoster(widget.album); + + return SafeArea( + top: false, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(kSpace16, 0, kSpace16, kSpace16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 海报(整块 RepaintBoundary;下载即渲染此区域)── + RepaintBoundary( + key: _posterKey, + child: Container( + width: double.infinity, + color: Colors.white, + child: Column( + children: [ + // 顶部宣传图:banner → cover → 品牌占位。 + AspectRatio( + aspectRatio: 16 / 9, + child: _bannerUrl != null + ? Image.network( + _bannerUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + _posterImageFallback(widget.album), + ) + : _posterImageFallback(widget.album), + ), + Padding( + padding: const EdgeInsets.all(kSpace24), + child: Column( + children: [ + // 活动标题。 + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + color: const Color(0xFF172033), + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: kSpace12), + // 时间。 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.event_outlined, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: kSpace4), + Flexible( + child: Text( + dateRange, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFF69778E), + ), + ), + ), + ], + ), + if (venue != null) ...[ + const SizedBox(height: kSpace4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.place_outlined, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: kSpace4), + Flexible( + child: Text( + address == null || address.isEmpty + ? venue + : '$venue · $address', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith( + color: const Color(0xFF69778E), + ), + ), + ), + ], + ), + ], + const SizedBox(height: kSpace24), + // 二维码(白底保持扫码对比度)。 + QrImageView( + data: url, + version: QrVersions.auto, + size: 200, + backgroundColor: Colors.white, + ), + const SizedBox(height: kSpace12), + Text( + l10n.shareScanHint, + style: theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFF69778E), + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: kSpace16), + // ── 下载海报图 ── + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 18), + label: Text(l10n.posterDownload), + ), + ], + ), + ), + ), + ); + } + + /// 宣传图缺省回退:封面 → 品牌色占位。 + Widget _posterImageFallback(Album album) { + final cover = album.cover?.url; + if (cover != null) { + return Image.network( + cover, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _brandPlaceholder(), + ); + } + return _brandPlaceholder(); + } + + Widget _brandPlaceholder() => Container( + color: const Color(0xFF12233E), + alignment: Alignment.center, + child: const Text( + 'PhotoLive', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: -0.8, + ), + ), + ); } diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 2faa9be..16bd0d1 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -423,6 +423,14 @@ packages: description: flutter source: sdk version: "0.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: f71e79840fe023a21f2f949771375444b6efcd34b9e625d5f4f5504971380a77 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.3" glob: dependency: transitive description: @@ -836,6 +844,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.4.3" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 137a02a..8fe8816 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: intl: 0.20.2 timezone: ^0.11.1 url_launcher: ^6.3.2 + share_plus: ^13.3.0 + gal: ^2.3.3 dev_dependencies: flutter_test: diff --git a/apps/mobile/test/pages/share_qr_page_test.dart b/apps/mobile/test/pages/share_qr_page_test.dart index 77efd9c..17f5241 100644 --- a/apps/mobile/test/pages/share_qr_page_test.dart +++ b/apps/mobile/test/pages/share_qr_page_test.dart @@ -63,16 +63,25 @@ void main() { findsOneWidget, reason: '开始时间(相册时区;结束未定不伪造区间)+ 场地(展示回退链)', ); - // 二维码真实生成 + 扫码说明 + 可选中链接。 - expect(find.byType(QrImageView), findsOneWidget); - expect(find.text('Scan to view photos'), findsOneWidget); - // 测试环境 URL:默认 VIEWER_BASE + 正确 token + en 语言参数。 + // UI06 三方式:系统分享(默认)/ 生成二维码海报 / 复制链接。 + expect(find.text('Share to WeChat / other apps'), findsOneWidget); + expect(find.text('Generate QR poster'), findsOneWidget); + expect(find.text('Copy link'), findsOneWidget); + // 可选中链接(二维码与复制共用同一 URL)。 final url = 'http://10.0.2.2:5175/a/$token?lang=en'; expect( tester.widget(find.byType(SelectableText)).data, url, - reason: '二维码与复制共用同一 URL(?lang= 随 App 语言)', + reason: '链接文本(?lang= 随 App 语言)', ); + + // 点「生成二维码海报」→ 弹层:标题/时间/地址/二维码/下载按钮。 + await tester.tap(find.text('Generate QR poster')); + await tester.pumpAndSettle(); + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text('Scan to view photos'), findsOneWidget); + expect(find.text('Download poster image'), findsOneWidget); + expect(find.text('2026/9/18 14:30'), findsOneWidget); }); testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示',