/// 相册分享页(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 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( (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 Function(ShareCardRequest card); final shareLauncherProvider = Provider( (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 compressShareThumbnail(String? url) async { if (url == null) return null; try { final resp = await Dio().get>( 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 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}); final Album album; Future _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 _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 _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); // 海报日期:日期粒度(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, 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( // 标题/时间/地址居左(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, ), ), const SizedBox(height: kSpace12), Center( child: 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, ), ), ); }