2026-09-22 02:45:34 +08:00
|
|
|
|
/// 相册分享页(UI-03 §4 页面 19 重设计;原 UI-01 分享对话框升级为全屏
|
|
|
|
|
|
/// 分享页,文件名保留 share_qr_dialog.dart 兼容既有引用)。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
///
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 手机全屏页(平板内容居中收窄为卡片,同一布局代码两条宽度分支):
|
|
|
|
|
|
/// 真实封面(可缺省占位)→ 名称/日期地点 → 现场生成二维码(白底突出,
|
|
|
|
|
|
/// data 为观众链接 `{VIEWER_BASE}/a/{token}?lang=`)→ 扫码说明 → Copy
|
|
|
|
|
|
/// link(主操作,复制同一链接)→ Open in browser(次操作,经 url_launcher
|
|
|
|
|
|
/// 外链打开)。相册未公开时以琥珀提示明确「该链接目前无法公开浏览」,
|
|
|
|
|
|
/// 不写「任何人可查看」;URL 文本可长按选中复制(复制失败的选择文本
|
|
|
|
|
|
/// 回退),但公开 token 不作主视觉编号。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter/services.dart';
|
2026-09-21 17:20:43 +08:00
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
2026-09-21 17:20:43 +08:00
|
|
|
|
import 'package:url_launcher/url_launcher.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
import '../core/config.dart';
|
2026-09-22 02:45:34 +08:00
|
|
|
|
import '../features/albums/album_format.dart';
|
|
|
|
|
|
import '../features/albums/models.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
import '../l10n/app_localizations.dart';
|
2026-09-22 02:45:34 +08:00
|
|
|
|
import '../ui/components/photo_thumbnail.dart';
|
2026-09-21 17:20:43 +08:00
|
|
|
|
import '../ui/tokens.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-21 17:20:43 +08:00
|
|
|
|
/// 外链打开注入点(生产=url_launcher;测试 override)。
|
|
|
|
|
|
typedef UrlLauncher = Future<bool> Function(String url);
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 观众分享链接语言参数(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),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-21 17:20:43 +08:00
|
|
|
|
/// Provider:外部浏览器打开 URL(LaunchMode.externalApplication——浏览器
|
|
|
|
|
|
/// 才能承载观众页的全部交互,内置 WebView 不适用)。
|
|
|
|
|
|
final urlLauncherProvider = Provider<UrlLauncher>(
|
2026-09-22 02:45:34 +08:00
|
|
|
|
(ref) => (url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 观众分享页([album] 来自相册详情;手机全屏 / 宽屏内容居中卡片)。
|
|
|
|
|
|
class ShareQrPage extends ConsumerWidget {
|
|
|
|
|
|
const ShareQrPage({required this.album, super.key});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
final Album album;
|
2026-09-21 17:20:43 +08:00
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 17:20:43 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
@override
|
2026-09-21 17:20:43 +08:00
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
2026-09-19 01:56:38 +08:00
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final theme = Theme.of(context);
|
2026-09-22 02:45:34 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-22 02:45:34 +08:00
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|