feat(mobile): UI-03 photos and share pages (17-20)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 02:45:34 +08:00
parent ab51616c70
commit 8ee69598bf
14 changed files with 1338 additions and 379 deletions
+153 -87
View File
@@ -1,14 +1,13 @@
/// 分享二维码对话框(UI-01 §4.5 分层重构,P3 计划 Task 4 基础)。
/// 相册分享页(UI-03 §4 页面 19 重设计;原 UI-01 分享对话框升级为全屏
/// 分享页,文件名保留 share_qr_dialog.dart 兼容既有引用)。
///
/// 分层:相册标题 → 二维码(白底突出,暗色主题保持扫码对比度)→
/// 链接文本(可选中)→ 操作行(复制链接为主操作,「在浏览器中打开」为
/// 次操作,经 url_launcher 外链打开)。viewer 链接构造:
/// `{VIEWER_BASE}/a/{publicToken}`([ApiConfig.viewerAlbumUrl])。
///
/// 用 [Dialog] 而非 [AlertDialog]:AlertDialog 内容经 IntrinsicWidth
/// 测量,而 QrImageView 内部使用 LayoutBuilder(不支持 intrinsic 计算)
/// 会抛布局断言,普通 Dialog 无此路径。外链打开经 Provider 注入
/// ([urlLauncherProvider]),widget 测试 override 替身免平台通道。
/// 手机全屏页(平板内容居中收窄为卡片,同一布局代码两条宽度分支):
/// 真实封面(可缺省占位)→ 名称/日期地点 → 现场生成二维码(白底突出,
/// data 为观众链接 `{VIEWER_BASE}/a/{token}?lang=`)→ 扫码说明 → Copy
/// link(主操作,复制同一链接)→ Open in browser(次操作,经 url_launcher
/// 外链打开)。相册未公开时以琥珀提示明确「该链接目前无法公开浏览」,
/// 不写「任何人可查看」;URL 文本可长按选中复制(复制失败的选择文本
/// 回退),但公开 token 不作主视觉编号。
library;
import 'package:flutter/material.dart';
@@ -18,28 +17,37 @@ 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),
(ref) => (url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
);
/// 观众分享二维码对话框([publicToken] 来自相册详情;[albumTitle] 可选
/// 分层标题,缺省回退通用标题)。
class ShareQrDialog extends ConsumerWidget {
const ShareQrDialog({required this.publicToken, super.key, this.albumTitle});
/// 观众分享页([album] 来自相册详情;手机全屏 / 宽屏内容居中卡片)。
class ShareQrPage extends ConsumerWidget {
const ShareQrPage({required this.album, super.key});
final String publicToken;
/// 相册展示名(当前界面语言回退解析后的标题)。
final String? albumTitle;
final Album album;
Future<void> _copy(BuildContext context, String url) async {
await Clipboard.setData(ClipboardData(text: url));
@@ -65,76 +73,134 @@ class ShareQrDialog extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final url = ApiConfig.viewerAlbumUrl(publicToken);
return Dialog(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 分层 1:相册标题(回退通用标题)。
Text(
albumTitle ?? l10n.shareQrTitle,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: kSpace4),
Text(
l10n.shareQrTitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: kSpace16),
// 分层 2:二维码(白底突出)。
Center(
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(kSpace8),
child: QrImageView(
data: url,
version: QrVersions.auto,
size: 216,
backgroundColor: Colors.white,
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),
),
],
),
),
),
const SizedBox(height: kSpace12),
// 分层 3:链接文本(可长按选中)。
SelectableText(
url,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: kSpace8),
Text(
l10n.qrHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: kSpace12),
// 分层 4:操作行——复制为主操作,浏览器打开为次操作。
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => _copy(context, url),
icon: const Icon(Icons.copy_outlined, size: 18),
label: Text(l10n.copyLink),
),
),
const SizedBox(width: kSpace8),
TextButton(
onPressed: () => _openInBrowser(context, ref, url),
child: Text(l10n.openInBrowser),
),
],
),
],
),
),
),
);