Files
photo-live/apps/mobile/lib/pages/share_qr_dialog.dart
T

587 lines
22 KiB
Dart
Raw Normal View History

/// 相册分享页(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);
// 海报日期:日期粒度(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,
),
),
);
}