5a207b395e
- 按钮文字「分享到微信/其他应用」→「分享到...」(用户需求) - 微信卡片分享(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>
582 lines
22 KiB
Dart
582 lines
22 KiB
Dart
/// 相册分享页(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,
|
||
),
|
||
),
|
||
);
|
||
}
|