Files
photo-live/apps/mobile/lib/pages/share_qr_dialog.dart
T
浅念 a02d7a5e98 feat(app): 海报弹层内中/English 切换——二维码海报支持英文版
- 弹层顶部 SegmentTabs(light) 两档,默认跟 App 语言前缀;只影响海报内容
  与二维码 ?lang=,不动 App 语言设置
- 提示文案经 lookupAppLocalizations 按海报语言取(arb 中英双全,零硬编码)
- 标题/场地/地址复用相册多语言回退链按海报语言取
- QrImageView 补 semanticsLabel(读屏原本无名,兼作测试断言口)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 11:04:13 +08:00

612 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册分享页(UI-06 重设计:三种分享方式,用户需求 2026-09-23)。
///
/// 1. 卡片模式(默认):系统分享面板(share_plus)——微信 / 手机上其他应用,
/// 分享文本 = 相册标题 + 观众链接;
/// 2. 生成二维码:弹出**二维码海报**(顶部宣传图 → 活动标题 / 时间 / 地址 /
/// 二维码),可**下载海报图**到相册(gal,RepaintBoundary 渲染 3x PNG);
/// 3. 复制链接。
/// 相册未公开时琥珀提示明确「该链接目前无法公开浏览」;URL 文本可长按复制。
library;
import 'dart:ui' as ui;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluwx/fluwx.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';
import '../l10n/app_localizations.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/components/segment_tabs.dart';
import '../ui/tokens.dart';
/// 外链打开注入点(生产=url_launcher;测试 override)。
typedef UrlLauncher = Future<bool> Function(String url);
/// 观众分享链接语言参数(viewer i18n 仅认 zh-CN/en;App locale 前缀映射)。
String shareLangParam(String localeName) =>
localeName.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
/// 分享链接(二维码 / 复制 / 系统分享同一 URL)。
String shareAlbumUrl(Album album, String localeName) =>
ApiConfig.viewerAlbumUrl(
album.publicToken,
lang: shareLangParam(localeName),
);
/// Provider:外部浏览器打开 URL。
final urlLauncherProvider = Provider<UrlLauncher>(
(ref) =>
(url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
);
/// 分享请求(卡片字段:微信 SDK 用全集;系统分享回退用 title+url)。
class ShareCardRequest {
const ShareCardRequest({
required this.title,
required this.description,
required this.url,
this.thumbnailUrl,
});
final String title;
final String description;
final String url;
/// 卡片缩略图(封面 URL;null=无图,微信显示默认图标)。
final String? thumbnailUrl;
}
/// 分享注入点(生产:微信已配置且安装→WXWebpage 卡片;否则系统分享面板。
/// 测试 override)。
typedef ShareLauncher = Future<void> Function(ShareCardRequest card);
final shareLauncherProvider = Provider<ShareLauncher>(
(ref) => (card) async {
const wechatAppId = String.fromEnvironment('WECHAT_APPID');
if (wechatAppId.isNotEmpty && await Fluwx().isWeChatInstalled) {
final thumb = await compressShareThumbnail(card.thumbnailUrl);
await Fluwx().share(
WeChatShareWebPageModel(
card.url,
title: card.title,
description: card.description,
thumbData: thumb,
scene: WeChatScene.session,
),
);
return;
}
// 回退:系统分享面板(文本=标题+链接;接收应用自行渲染链接)。
// share_plus 11:SharePlus.instance.share(旧 Share.share 已废弃)。
await SharePlus.instance.share(
ShareParams(text: '${card.title}\n${card.url}', subject: card.title),
);
},
);
/// 微信卡片缩略图:下载→缩到 240px→PNG(微信 thumbData 限制 ≤32KB)。
Future<Uint8List?> compressShareThumbnail(String? url) async {
if (url == null) return null;
try {
final resp = await Dio().get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
);
final data = Uint8List.fromList(resp.data!);
final codec = await ui.instantiateImageCodec(data, targetWidth: 240);
final frame = await codec.getNextFrame();
final resized = await frame.image.toByteData(
format: ui.ImageByteFormat.png,
);
return resized?.buffer.asUint8List();
} on Object {
// 缩略图失败不阻塞分享(无图卡片仍可发)
return null;
}
}
/// 海报保存相册注入点(生产=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});
final Album album;
Future<void> _copy(BuildContext context, String url) async {
await Clipboard.setData(ClipboardData(text: url));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).copiedToClipboard)),
);
}
Future<void> _shareToApps(
BuildContext context,
WidgetRef ref,
String url,
String title,
) async {
final l10n = AppLocalizations.of(context);
final localeName = l10n.localeName;
final start = formatAlbumDateTime(
album.startAt,
album.timezone,
localeName,
);
final end = album.endAt == null
? null
: formatAlbumDateTime(album.endAt!, album.timezone, localeName);
final dateRange = end == null ? start : '$start ~ $end';
final venue = albumDisplayVenue(album, localeName);
final description = venue == null ? dateRange : '$dateRange · $venue';
// 缩略图用封面(列表即有签名 URL,减少一次串行请求)。
await ref.read(shareLauncherProvider)(
ShareCardRequest(
title: title,
description: description,
url: url,
thumbnailUrl: album.cover?.url,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
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 end = album.endAt == null
? null
: formatAlbumDateTime(album.endAt!, album.timezone, localeName);
final dateRange = end == null ? start : '$start ~ $end';
final meta = venue == null ? dateRange : '$dateRange · $venue';
return Scaffold(
appBar: AppBar(title: Text(l10n.shareAlbum)),
body: SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Card(
margin: const EdgeInsets.all(kSpace16),
child: Padding(
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 相册信息(封面 + 标题 + meta)。
ClipRRect(
borderRadius: BorderRadius.circular(kRadiusInput),
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
maxCacheWidth: 600,
),
),
const SizedBox(height: kSpace16),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: kSpace4),
Text(
meta,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (!album.publicEnabled) ...[
const SizedBox(height: kSpace12),
Container(
padding: const EdgeInsets.all(kSpace12),
decoration: BoxDecoration(
color: theme.brightness == Brightness.light
? kColorWaiting.withValues(alpha: 0.12)
: kColorWaitingDark.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 18,
color: theme.brightness == Brightness.light
? kColorWaiting
: kColorWaitingDark,
),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
l10n.shareNotPublic,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.brightness == Brightness.light
? kColorWaiting
: kColorWaitingDark,
),
),
),
],
),
),
],
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),
// 链接文本(长按选中复制;浏览器打开为次级入口)。
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),
),
],
),
),
),
),
),
),
);
}
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)。
///
/// 弹层内「中文/English」切换海报语言(默认跟 App 语言):内容文案/相册
/// 字段/二维码 `?lang=` 随切换,App 语言设置与弹层 chrome 不受影响。
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;
/// 海报语言('zh'|'en';打开时刻快照 App 语言前缀,切换只影响海报)。
late String _posterLang =
AppLocalizations.of(context).localeName.toLowerCase().startsWith('zh')
? 'zh'
: 'en';
@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);
// 海报内容按海报语言渲染(弹层 chrome 仍跟 App 语言)。
final posterL10n = lookupAppLocalizations(Locale(_posterLang));
final url = shareAlbumUrl(widget.album, _posterLang);
final title = albumDisplayTitle(widget.album, _posterLang);
// 海报日期:日期粒度(UI06 选择器已去时间;展示同口径)。
String posterDate(DateTime utc) {
final wall = tryToAlbumZone(utc, widget.album.timezone) ?? utc.toUtc();
return '${wall.year}/${wall.month}/${wall.day}';
}
final start = posterDate(widget.album.startAt);
final end = widget.album.endAt == null
? null
: posterDate(widget.album.endAt!);
final dateRange = end == null ? start : '$start ~ $end';
final venue = albumDisplayVenue(widget.album, _posterLang);
String? addressForPoster(AlbumSummary album) {
for (final t in albumDisplayTranslations(album, _posterLang)) {
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: [
// ── 头行:标题 + 显式关闭(海报较长时下拉手势不明显)──
Row(
children: [
Expanded(
child: Text(
l10n.sharePosterMode,
style: theme.textTheme.titleMedium,
),
),
IconButton(
tooltip: l10n.close,
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: kSpace8),
// ── 海报语言切换:内容/二维码随之,App 语言不动 ──
SegmentTabs<String>(
value: _posterLang,
onChanged: (lang) => setState(() => _posterLang = lang),
style: SegmentTabsStyle.light,
segments: [
('zh', l10n.localeChinese),
('en', l10n.localeEnglish),
],
),
const SizedBox(height: kSpace12),
// ── 海报(整块 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(
// 标题/时间/地址居左(Column 默认 center 会吞掉
// Text 的 textAlign——对齐由这里统一决定)。
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 活动标题(居左)。
Text(
title,
textAlign: TextAlign.left,
style: theme.textTheme.titleLarge?.copyWith(
color: const Color(0xFF172033),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: kSpace12),
// 时间(居左)。
Row(
mainAxisAlignment: MainAxisAlignment.start,
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.start,
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),
// 二维码 + 说明:保持居中(文字区居左的例外)。
Center(
child: QrImageView(
data: url,
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
semanticsLabel: url,
),
),
const SizedBox(height: kSpace12),
Center(
child: Text(
posterL10n.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,
),
),
);
}