Files
photo-live/apps/mobile/lib/pages/share_qr_dialog.dart
T
2026-09-22 02:45:34 +08:00

209 lines
8.9 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-03 §4 页面 19 重设计;原 UI-01 分享对话框升级为全屏
/// 分享页,文件名保留 share_qr_dialog.dart 兼容既有引用)。
///
/// 手机全屏页(平板内容居中收窄为卡片,同一布局代码两条宽度分支):
/// 真实封面(可缺省占位)→ 名称/日期地点 → 现场生成二维码(白底突出,
/// data 为观众链接 `{VIEWER_BASE}/a/{token}?lang=`)→ 扫码说明 → Copy
/// link(主操作,复制同一链接)→ Open in browser(次操作,经 url_launcher
/// 外链打开)。相册未公开时以琥珀提示明确「该链接目前无法公开浏览」,
/// 不写「任何人可查看」;URL 文本可长按选中复制(复制失败的选择文本
/// 回退),但公开 token 不作主视觉编号。
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.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):`{VIEWER_BASE}/a/{token}?lang=`。
String shareAlbumUrl(Album album, String localeName) =>
ApiConfig.viewerAlbumUrl(
album.publicToken,
lang: shareLangParam(localeName),
);
/// Provider:外部浏览器打开 URL(LaunchMode.externalApplication——浏览器
/// 才能承载观众页的全部交互,内置 WebView 不适用)。
final urlLauncherProvider = Provider<UrlLauncher>(
(ref) => (url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
);
/// 观众分享页([album] 来自相册详情;手机全屏 / 宽屏内容居中卡片)。
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> _openInBrowser(
BuildContext context,
WidgetRef ref,
String url,
) async {
final launched = await ref.read(urlLauncherProvider)(url);
if (!launched || !context.mounted) return;
Navigator.of(context).pop();
}
@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 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: [
// 真实封面(无封面同尺寸中性占位)。
ClipRRect(
borderRadius: BorderRadius.circular(kRadiusInput),
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
),
),
const SizedBox(height: kSpace16),
Text(
albumDisplayTitle(album, localeName),
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,
),
),
const SizedBox(height: kSpace16),
// 二维码(白底突出,暗色主题保持扫码对比度)。
Center(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(kSpace12),
child: QrImageView(
data: url,
version: QrVersions.auto,
size: 216,
backgroundColor: Colors.white,
),
),
),
const SizedBox(height: kSpace12),
Text(
l10n.shareScanHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: kSpace8),
// 链接文本(可长按选中复制;弱化呈现,不作主视觉)。
SelectableText(
url,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.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: kSpace16),
// Copy link 主操作;浏览器打开为次级入口。
FilledButton.icon(
onPressed: () => _copy(context, url),
icon: const Icon(Icons.copy_outlined, size: 18),
label: Text(l10n.copyLink),
),
const SizedBox(height: kSpace8),
OutlinedButton.icon(
onPressed: () => _openInBrowser(context, ref, url),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(l10n.openInBrowser),
),
],
),
),
),
),
),
),
);
}
}