Files
photo-live/apps/mobile/lib/pages/share_qr_dialog.dart
T
浅念 5a207b395e feat(app,viewer): WeChat card share + 'Share to...' label + site og tags
- 按钮文字「分享到微信/其他应用」→「分享到...」(用户需求)
- 微信卡片分享(fluwx 6):WECHAT_APPID 经 --dart-define 注入;已配置且
  装微信 → WXWebpage 卡片(标题=相册名、描述=日期·地点、缩略图=封面压缩
  240px PNG ≤32KB、点击进观众链接);未配置/未装 → 回退系统分享面板
- viewer index.html 加站点级 og 标签(微信/QQ 抓取链接预览用;每相册动态
  og 需服务端注入,登记后续增强)
- 397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:05:53 +08:00

582 lines
22 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/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,
),
),
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)。
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: [
// ── 头行:标题 + 显式关闭(海报较长时下拉手势不明显)──
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(),
),
],
),
// ── 海报(整块 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,
),
),
);
}