feat(app): share redesign — card share (system), QR poster (banner+title+time+venue+QR, downloadable), copy link
- 三方式(用户需求):①卡片模式默认=系统分享面板(share_plus SharePlus. instance,微信/其他应用,文本=标题+链接)②生成二维码=海报弹层——顶部 宣传图(banner→cover→品牌占位回退)+ 活动标题 + 时间 + 地址 + 二维码, 「下载海报图」RepaintBoundary 3x 渲染 PNG → gal 存相册 ③复制链接 - 依赖 share_plus + gal;分享/保存均 Provider 注入(测试替身) - 测试适配三方式结构 + 海报弹层断言(397/397 全绿、analyze 0) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '海报保存失败';
|
||||
}
|
||||
|
||||
@@ -421,5 +421,10 @@
|
||||
"bannerPicker": "宣传图",
|
||||
"bannerPickerHint": "点击选择宣传图(展示在观众页顶部)",
|
||||
"bannerSelected": "已选择宣传图——保存时上传",
|
||||
"bannerUploadFailed": "宣传图上传失败"
|
||||
"bannerUploadFailed": "宣传图上传失败",
|
||||
"shareCardMode": "分享到微信 / 其他应用",
|
||||
"sharePosterMode": "生成二维码海报",
|
||||
"posterDownload": "下载海报图",
|
||||
"posterSaved": "海报已保存到相册",
|
||||
"posterSaveFailed": "海报保存失败"
|
||||
}
|
||||
|
||||
@@ -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<bool> 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<UrlLauncher>(
|
||||
(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<void> Function(String text);
|
||||
|
||||
final shareLauncherProvider = Provider<ShareLauncher>(
|
||||
// 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<void> Function(ui.Image image);
|
||||
|
||||
final posterSaverProvider = Provider<PosterSaver>(
|
||||
(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<void> _openInBrowser(
|
||||
Future<void> _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<void> _openPoster(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
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<void>.microtask(() async {
|
||||
try {
|
||||
final url = await ref
|
||||
.read(apiClientProvider)
|
||||
.fetchAlbumAssetUrl(widget.album.id, 'banner');
|
||||
if (mounted) setState(() => _bannerUrl = url);
|
||||
} on Object {
|
||||
// 拉不到 banner 时海报用封面兜底
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<SelectableText>(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 + 已复制提示',
|
||||
|
||||
Reference in New Issue
Block a user