/// 相册分享页(UI-06 重设计:三种分享方式,用户需求 2026-09-23)。 /// /// 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'; 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), ); /// 系统分享注入点(生产=share_plus;测试 override)。 typedef ShareLauncher = Future Function(String text); final shareLauncherProvider = Provider( // 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 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 { await ref.read(shareLauncherProvider)('$title\n$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); 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, ), ), ); }