diff --git a/apps/mobile/lib/core/config.dart b/apps/mobile/lib/core/config.dart index a63c034..f66c204 100644 --- a/apps/mobile/lib/core/config.dart +++ b/apps/mobile/lib/core/config.dart @@ -20,7 +20,10 @@ class ApiConfig { ); /// 观众端相册链接:`{VIEWER_BASE}/a/{token}`(Task 4 qr 分享用, - /// viewer 路由约定)。 - static String viewerAlbumUrl(String publicToken) => - '$viewerBase/a/$publicToken'; + /// viewer 路由约定)。[lang] 附加 `?lang=`(viewer i18n 分享链接语言 + /// 参数,UI-03 页面 19:接收端据此呈现,仅认 zh-CN/en)。 + static String viewerAlbumUrl(String publicToken, {String? lang}) { + final base = '$viewerBase/a/$publicToken'; + return lang == null ? base : '$base?lang=$lang'; + } } diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 75ca9d1..62dc65c 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -283,7 +283,7 @@ "photoHide": "Hide", "photoUnhide": "Unhide", "photoDeleteConfirmTitle": "Delete this photo?", - "photoDeleteConfirmBody": "The cloud photo will be deleted. This cannot be undone.", + "photoDeleteConfirmBody": "This will soft-delete the photo. It can be managed by admins. Cloud storage is not immediately purged.", "photoHiddenToast": "Hidden", "photoUnhiddenToast": "Unhidden", "photoDeletedToast": "Deleted", @@ -303,5 +303,18 @@ "emptyAlbumSubtitle": "Photos you take or pick will appear here", "processingPhotos": "Processing...", "readonlyMember": "You can browse photos shared by others", - "moreActions": "More actions" + "moreActions": "More actions", + "photosTitle": "{count} photos", + "@photosTitle": { "placeholders": { "count": { "type": "int" } } }, + "photoUploadedBy": "Uploaded by {name}", + "@photoUploadedBy": { "placeholders": { "name": { "type": "String" } } }, + "photoDimensions": "{width}×{height}", + "@photoDimensions": { "placeholders": { "width": { "type": "int" }, "height": { "type": "int" } } }, + "photoStatus": "Status", + "photoHideAction": "Hide photo", + "photoPublishAction": "Publish again", + "shareScanHint": "Scan to view photos", + "shareNotPublic": "This link is not publicly accessible yet", + "hidePhotoTitle": "Hide this photo?", + "hidePhotoBody": "Viewers will no longer see this photo. Authorized album managers can publish it again." } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 83c7066..038621c 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1727,7 +1727,7 @@ abstract class AppLocalizations { /// No description provided for @photoDeleteConfirmBody. /// /// In zh, this message translates to: - /// **'云端照片将被删除,删除后不可恢复。'** + /// **'该照片将被软删除,管理员仍可管理,云端存储不会立即清除。'** String get photoDeleteConfirmBody; /// No description provided for @photoHiddenToast. @@ -1849,6 +1849,66 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'更多操作'** String get moreActions; + + /// No description provided for @photosTitle. + /// + /// In zh, this message translates to: + /// **'{count} 张照片'** + String photosTitle(int count); + + /// No description provided for @photoUploadedBy. + /// + /// In zh, this message translates to: + /// **'上传者 {name}'** + String photoUploadedBy(String name); + + /// No description provided for @photoDimensions. + /// + /// In zh, this message translates to: + /// **'{width}×{height}'** + String photoDimensions(int width, int height); + + /// No description provided for @photoStatus. + /// + /// In zh, this message translates to: + /// **'状态'** + String get photoStatus; + + /// No description provided for @photoHideAction. + /// + /// In zh, this message translates to: + /// **'隐藏照片'** + String get photoHideAction; + + /// No description provided for @photoPublishAction. + /// + /// In zh, this message translates to: + /// **'重新发布'** + String get photoPublishAction; + + /// No description provided for @shareScanHint. + /// + /// In zh, this message translates to: + /// **'扫码查看照片'** + String get shareScanHint; + + /// No description provided for @shareNotPublic. + /// + /// In zh, this message translates to: + /// **'该链接目前无法公开浏览'** + String get shareNotPublic; + + /// No description provided for @hidePhotoTitle. + /// + /// In zh, this message translates to: + /// **'隐藏这张照片?'** + String get hidePhotoTitle; + + /// No description provided for @hidePhotoBody. + /// + /// In zh, this message translates to: + /// **'观众将不再看到这张照片。相册管理者可重新发布。'** + String get hidePhotoBody; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 39cbe32..0c4b63b 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -876,7 +876,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get photoDeleteConfirmBody => - 'The cloud photo will be deleted. This cannot be undone.'; + 'This will soft-delete the photo. It can be managed by admins. Cloud storage is not immediately purged.'; @override String get photoHiddenToast => 'Hidden'; @@ -937,4 +937,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreActions => 'More actions'; + + @override + String photosTitle(int count) { + return '$count photos'; + } + + @override + String photoUploadedBy(String name) { + return 'Uploaded by $name'; + } + + @override + String photoDimensions(int width, int height) { + return '$width×$height'; + } + + @override + String get photoStatus => 'Status'; + + @override + String get photoHideAction => 'Hide photo'; + + @override + String get photoPublishAction => 'Publish again'; + + @override + String get shareScanHint => 'Scan to view photos'; + + @override + String get shareNotPublic => 'This link is not publicly accessible yet'; + + @override + String get hidePhotoTitle => 'Hide this photo?'; + + @override + String get hidePhotoBody => + 'Viewers will no longer see this photo. Authorized album managers can publish it again.'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 1e38c3e..2ea9f01 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -847,7 +847,7 @@ class AppLocalizationsZh extends AppLocalizations { String get photoDeleteConfirmTitle => '删除这张照片?'; @override - String get photoDeleteConfirmBody => '云端照片将被删除,删除后不可恢复。'; + String get photoDeleteConfirmBody => '该照片将被软删除,管理员仍可管理,云端存储不会立即清除。'; @override String get photoHiddenToast => '已隐藏'; @@ -908,4 +908,40 @@ class AppLocalizationsZh extends AppLocalizations { @override String get moreActions => '更多操作'; + + @override + String photosTitle(int count) { + return '$count 张照片'; + } + + @override + String photoUploadedBy(String name) { + return '上传者 $name'; + } + + @override + String photoDimensions(int width, int height) { + return '$width×$height'; + } + + @override + String get photoStatus => '状态'; + + @override + String get photoHideAction => '隐藏照片'; + + @override + String get photoPublishAction => '重新发布'; + + @override + String get shareScanHint => '扫码查看照片'; + + @override + String get shareNotPublic => '该链接目前无法公开浏览'; + + @override + String get hidePhotoTitle => '隐藏这张照片?'; + + @override + String get hidePhotoBody => '观众将不再看到这张照片。相册管理者可重新发布。'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 3e53e49..49da3e3 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -283,7 +283,7 @@ "photoHide": "隐藏", "photoUnhide": "恢复显示", "photoDeleteConfirmTitle": "删除这张照片?", - "photoDeleteConfirmBody": "云端照片将被删除,删除后不可恢复。", + "photoDeleteConfirmBody": "该照片将被软删除,管理员仍可管理,云端存储不会立即清除。", "photoHiddenToast": "已隐藏", "photoUnhiddenToast": "已恢复显示", "photoDeletedToast": "已删除", @@ -303,5 +303,18 @@ "emptyAlbumSubtitle": "拍摄或选择的照片将出现在这里", "processingPhotos": "处理中...", "readonlyMember": "您可以浏览其他人分享的照片", - "moreActions": "更多操作" + "moreActions": "更多操作", + "photosTitle": "{count} 张照片", + "@photosTitle": { "placeholders": { "count": { "type": "int" } } }, + "photoUploadedBy": "上传者 {name}", + "@photoUploadedBy": { "placeholders": { "name": { "type": "String" } } }, + "photoDimensions": "{width}×{height}", + "@photoDimensions": { "placeholders": { "width": { "type": "int" }, "height": { "type": "int" } } }, + "photoStatus": "状态", + "photoHideAction": "隐藏照片", + "photoPublishAction": "重新发布", + "shareScanHint": "扫码查看照片", + "shareNotPublic": "该链接目前无法公开浏览", + "hidePhotoTitle": "隐藏这张照片?", + "hidePhotoBody": "观众将不再看到这张照片。相册管理者可重新发布。" } diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 5fdd5ee..7d4f609 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -219,12 +219,9 @@ class _AlbumDetailPageState extends ConsumerState { void _shareQr() { final album = _album; if (album == null) return; - showDialog( - context: context, - builder: (_) => ShareQrDialog( - publicToken: album.publicToken, - albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName), - ), + // UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。 + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ShareQrPage(album: album)), ); } diff --git a/apps/mobile/lib/pages/album_photo_viewer_page.dart b/apps/mobile/lib/pages/album_photo_viewer_page.dart index 13ade26..2f3fc96 100644 --- a/apps/mobile/lib/pages/album_photo_viewer_page.dart +++ b/apps/mobile/lib/pages/album_photo_viewer_page.dart @@ -1,16 +1,21 @@ -/// 照片大图查看器(UI-01 §4.3):已加载照片间左右切换、临近边缘加载 -/// 更多、contain 完整展示;底部信息栏(上传者 / 相册时区时间 / 尺寸大 -/// 小,photoId 放诊断区域);隐藏/恢复/删除单张操作(权限校验 + 删除 -/// 确认「云端照片将被删除」);签名 URL 过期受控刷新一次。 +/// 照片大图查看器(UI-01 §4.3;UI-03 §4 页面 18 重设计):摄影深色背景 +/// (恒深色 #10161E,不随系统主题)+ contain 完整展示 + 左右滑动/方向键 +/// 切换 + 临近边缘加载更多;顶栏关闭 × 与「⋯」更多菜单;底部资料面板 +/// (上传者 / 相册时区时间 · 尺寸 · 大小 / 真实可见性状态 / photoId 诊断 +/// 区)+ 操作行 Hide photo|Publish again(权限裁决)与 Delete(红色, +/// 独立确认说明软删除语义)。 /// -/// 关闭后回到原网格位置(网格页状态与 ScrollController 在路由下保留)。 -/// 恢复发布仅 owner/admin([PhotoGridController.isManager]);隐藏/删除 -/// 为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。 +/// 工作人员管理页:不加入公众点赞/分享/下载工具栏。隐藏经轻量确认 +/// BottomSheet(UI-03 §4 页面 20,[HidePhotoConfirmSheet])——确认时捕获 +/// photoId,防切图错删;成功后关闭面板与查看器,回网格(滚动位置由网格 +/// 页状态保留)。恢复发布仅 owner/admin([PhotoGridController.isManager]); +/// 隐藏/删除为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。 library; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/app_exception.dart'; import '../core/error_l10n.dart'; @@ -18,6 +23,7 @@ import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; import '../features/photos/models.dart'; import '../l10n/app_localizations.dart'; +import '../ui/components/photo_thumbnail.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; import 'album_photos_page.dart'; @@ -41,19 +47,28 @@ class AlbumPhotoViewerPage extends StatefulWidget { State createState() => _AlbumPhotoViewerPageState(); } +/// 「⋯」更多菜单动作(面板 pop 返回,页面侧分发执行)。 +enum _ViewerMenuAction { hide, publish, delete } + class _AlbumPhotoViewerPageState extends State { late final PageController _pageController; + late final FocusNode _focusNode; late int _index; @override void initState() { super.initState(); - _index = widget.initialIndex.clamp(0, math.max(widget.controller.items.length - 1, 0)); + _index = widget.initialIndex.clamp( + 0, + math.max(widget.controller.items.length - 1, 0), + ); _pageController = PageController(initialPage: _index); + _focusNode = FocusNode(); } @override void dispose() { + _focusNode.dispose(); _pageController.dispose(); super.dispose(); } @@ -70,6 +85,26 @@ class _AlbumPhotoViewerPageState extends State { } } + /// 方向键切换(键盘/遥控可达性;与滑动同一条 PageView 序列)。 + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final itemCount = widget.controller.items.length; + final target = switch (event.logicalKey) { + LogicalKeyboardKey.arrowLeft => _index - 1, + LogicalKeyboardKey.arrowRight => _index + 1, + _ => _index, + }; + if (target == _index || target < 0 || target >= itemCount) { + return KeyEventResult.ignored; + } + _pageController.animateToPage( + target, + duration: motion(context, carousel: true), + curve: Curves.easeOutCubic, + ); + return KeyEventResult.handled; + } + bool _isOwnUpload(AdminPhoto photo) => widget.controller.selfAccount != null && photo.uploader.account == widget.controller.selfAccount; @@ -88,19 +123,23 @@ class _AlbumPhotoViewerPageState extends State { photo.visibility != PhotoVisibility.deleted && (widget.controller.isManager || _isOwnUpload(photo)); - Future _hide(AdminPhoto photo) async { + /// 隐藏确认(页面 20):轻量 BottomSheet;API 调用在面板内执行(提交中 + /// 防重复、失败面板内提示可重试),photoId 打开时捕获。 + Future _confirmAndHide(AdminPhoto photo) async { final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); - try { - await widget.controller.hidePhoto(photo); - messenger.showSnackBar(SnackBar(content: Text(l10n.photoHiddenToast))); - } on AppException catch (e) { - messenger.showSnackBar( - SnackBar(content: Text(l10n.messageForError(e))), - ); - } on Object { - messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); - } + final hidden = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => HidePhotoConfirmSheet( + photo: photo, + onConfirm: widget.controller.hidePhoto, + ), + ); + if (hidden != true || !mounted) return; + // 成功:回网格(条目状态已合并,瓦片角标翻转为 Hidden)。 + Navigator.of(context).pop(); + messenger.showSnackBar(SnackBar(content: Text(l10n.photoHiddenToast))); } Future _unhide(AdminPhoto photo) async { @@ -110,9 +149,7 @@ class _AlbumPhotoViewerPageState extends State { await widget.controller.unhidePhoto(photo); messenger.showSnackBar(SnackBar(content: Text(l10n.photoUnhiddenToast))); } on AppException catch (e) { - messenger.showSnackBar( - SnackBar(content: Text(l10n.messageForError(e))), - ); + messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e)))); } on Object { messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); } @@ -120,7 +157,8 @@ class _AlbumPhotoViewerPageState extends State { Future _delete(AdminPhoto photo) async { final l10n = AppLocalizations.of(context); - // 删除确认:明确影响云端照片(与本机任务记录清理区分)。 + // 删除确认(与隐藏确认分开):说明当前实际软删除语义——标记 deleted + // 公开端点即刻不可见,物理删除走独立运维流程(openapi deletePhoto)。 final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( @@ -154,14 +192,61 @@ class _AlbumPhotoViewerPageState extends State { }); } } on AppException catch (e) { - messenger.showSnackBar( - SnackBar(content: Text(l10n.messageForError(e))), - ); + messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e)))); } on Object { messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); } } + /// 「⋯」更多菜单:与底部操作行同一套权限裁决(先关面板再执行)。 + Future _showMoreMenu(AdminPhoto photo) async { + final l10n = AppLocalizations.of(context); + final action = await showModalBottomSheet<_ViewerMenuAction>( + context: context, + showDragHandle: true, + builder: (_) => SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_canHide(photo)) + ListTile( + leading: const Icon(Icons.visibility_off_outlined), + title: Text(l10n.photoHideAction), + onTap: () => Navigator.of(context).pop(_ViewerMenuAction.hide), + ), + if (_canUnhide(photo)) + ListTile( + leading: const Icon(Icons.visibility_outlined), + title: Text(l10n.photoPublishAction), + onTap: () => + Navigator.of(context).pop(_ViewerMenuAction.publish), + ), + if (_canDelete(photo)) + ListTile( + leading: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + ), + title: Text(l10n.delete), + onTap: () => + Navigator.of(context).pop(_ViewerMenuAction.delete), + ), + ], + ), + ), + ); + if (!mounted || action == null) return; + switch (action) { + case _ViewerMenuAction.hide: + await _confirmAndHide(photo); + case _ViewerMenuAction.publish: + await _unhide(photo); + case _ViewerMenuAction.delete: + await _delete(photo); + } + } + @override Widget build(BuildContext context) { // 整个 Scaffold(含信息栏)随 controller 重建:隐藏/恢复/刷新合并 @@ -171,10 +256,15 @@ class _AlbumPhotoViewerPageState extends State { builder: (context, _) { final items = widget.controller.items; return Scaffold( - backgroundColor: Colors.black, + // 摄影深色背景(恒深色,不随系统主题;spec §3)。 + backgroundColor: kColorPhotoBackdrop, body: items.isEmpty ? const _ViewerShell(child: SizedBox.shrink()) : _ViewerShell( + onMore: () => + _showMoreMenu(items[_index.clamp(0, items.length - 1)]), + onKey: _onKey, + focusNode: _focusNode, child: PageView.builder( controller: _pageController, itemCount: items.length, @@ -192,14 +282,18 @@ class _AlbumPhotoViewerPageState extends State { ); } - /// 底部信息栏:上传者 / 时区时间 / 尺寸大小 + 操作行 + photoId 诊断区。 + /// 底部资料 + 操作栏(安全区避让):上传者 / 时区时间 · 尺寸 · 大小 / + /// 可见性状态 / photoId 诊断区 / Hide photo(Publish again)+ Delete。 Widget _infoBar(BuildContext context) { final l10n = AppLocalizations.of(context); final items = widget.controller.items; if (items.isEmpty) return const SizedBox.shrink(); final photo = items[_index.clamp(0, items.length - 1)]; + final uploader = photo.uploader.displayName.isEmpty + ? photo.uploader.account + : photo.uploader.displayName; final dims = (photo.width != null && photo.height != null) - ? '${photo.width}×${photo.height}' + ? l10n.photoDimensions(photo.width!, photo.height!) : null; final size = _formatBytes(photo.bytes); final meta = [ @@ -215,27 +309,32 @@ class _AlbumPhotoViewerPageState extends State { top: false, child: Container( color: Colors.black.withValues(alpha: 0.72), - padding: const EdgeInsets.symmetric( - horizontal: kSpace16, - vertical: kSpace8, + padding: const EdgeInsets.fromLTRB( + kSpace16, + kSpace12, + kSpace16, + kSpace8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - photo.uploader.displayName.isEmpty - ? photo.uploader.account - : photo.uploader.displayName, + l10n.photoUploadedBy(uploader), maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.white, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: Colors.white), ), const SizedBox(height: kSpace4), Text(meta, style: const TextStyle(color: Colors.white70)), const SizedBox(height: kSpace4), + Text( + '${l10n.photoStatus}: ${_visibilityLabel(l10n, photo.visibility)}', + style: const TextStyle(color: Colors.white70), + ), + const SizedBox(height: kSpace4), // 诊断区域:照片 ID(不与主信息争夺层级)。 Text( photo.photoId, @@ -245,34 +344,46 @@ class _AlbumPhotoViewerPageState extends State { context, ).textTheme.labelSmall?.copyWith(color: Colors.white38), ), - Wrap( - spacing: kSpace16, + const SizedBox(height: kSpace4), + Row( children: [ if (_canHide(photo)) - TextButton.icon( - onPressed: () => _hide(photo), - icon: const Icon(Icons.visibility_off_outlined), - label: Text(l10n.photoHide), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - ), - ), - if (_canUnhide(photo)) - TextButton.icon( - onPressed: () => _unhide(photo), - icon: const Icon(Icons.visibility_outlined), - label: Text(l10n.photoUnhide), - style: TextButton.styleFrom( - foregroundColor: Colors.white, + Expanded( + child: OutlinedButton.icon( + onPressed: () => _confirmAndHide(photo), + icon: const Icon(Icons.visibility_off_outlined, size: 18), + label: Text(l10n.photoHideAction), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), + ), + ) + else if (_canUnhide(photo)) + Expanded( + child: OutlinedButton.icon( + onPressed: () => _unhide(photo), + icon: const Icon(Icons.visibility_outlined, size: 18), + label: Text(l10n.photoPublishAction), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), ), ), + if (_canDelete(photo) && (_canHide(photo) || _canUnhide(photo))) + const SizedBox(width: kSpace12), if (_canDelete(photo)) - TextButton.icon( - onPressed: () => _delete(photo), - icon: const Icon(Icons.delete_outline), - label: Text(l10n.delete), - style: TextButton.styleFrom( - foregroundColor: Colors.white, + Expanded( + child: OutlinedButton.icon( + onPressed: () => _delete(photo), + icon: const Icon(Icons.delete_outline, size: 18), + label: Text(l10n.delete), + style: OutlinedButton.styleFrom( + // 深色表面用提亮错误变体保证对比度。 + foregroundColor: kColorErrorDark, + side: const BorderSide(color: kColorErrorDark), + ), ), ), ], @@ -283,6 +394,16 @@ class _AlbumPhotoViewerPageState extends State { ); } + static String _visibilityLabel( + AppLocalizations l10n, + PhotoVisibility visibility, + ) => switch (visibility) { + PhotoVisibility.published => l10n.visibilityPublished, + PhotoVisibility.unpublished => l10n.visibilityUnpublished, + PhotoVisibility.hidden => l10n.visibilityHidden, + PhotoVisibility.deleted => l10n.visibilityDeleted, + }; + static String? _formatBytes(int bytes) { if (bytes <= 0) return null; if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} KB'; @@ -290,29 +411,68 @@ class _AlbumPhotoViewerPageState extends State { } } -/// 查看器壳层:关闭按钮(安全区避让)+ 内容。 +/// 查看器壳层:顶栏(关闭 × + 更多 ⋯,白图标 + 半透明圆底,≥44dp 触控) +/// + 方向键焦点 + 内容。 class _ViewerShell extends StatelessWidget { - const _ViewerShell({required this.child}); + const _ViewerShell({ + required this.child, + this.onMore, + this.onKey, + this.focusNode, + }); final Widget child; + final VoidCallback? onMore; + final FocusOnKeyEventCallback? onKey; + final FocusNode? focusNode; @override Widget build(BuildContext context) { return Stack( children: [ - Positioned.fill(child: child), - SafeArea( - child: Align( - alignment: Alignment.topLeft, - child: IconButton( - tooltip: MaterialLocalizations.of(context).closeButtonTooltip, - onPressed: () => Navigator.of(context).pop(), - // 44×44 有效触控区(图标本身 24)。 - icon: const Icon(Icons.close, color: Colors.white), - style: IconButton.styleFrom( - backgroundColor: Colors.black45, - foregroundColor: Colors.white, - ), + Positioned.fill( + child: Focus( + autofocus: true, + focusNode: focusNode, + onKeyEvent: onKey, + child: child, + ), + ), + // 顶栏显式 Positioned:Stack 尺寸不随内容收缩(Row 会 shrink-wrap, + // 旧实现的 Align 自扩张——换成 Row 后必须定位,否则 PageView 被压 + // 到顶栏高度)。 + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + // 44×44 有效触控区(图标本身 24)。 + icon: const Icon(Icons.close, color: Colors.white), + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + foregroundColor: Colors.white, + ), + ), + if (onMore != null) + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).moreButtonTooltip, + onPressed: onMore, + icon: const Icon(Icons.more_vert, color: Colors.white), + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + foregroundColor: Colors.white, + ), + ), + ], ), ), ), @@ -343,7 +503,11 @@ class _ViewerPhotoCell extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.image_not_supported_outlined, size: 48, color: Colors.white38), + const Icon( + Icons.image_not_supported_outlined, + size: 48, + color: Colors.white38, + ), const SizedBox(height: kSpace12), StateBadge( variant: _stateBadgeVariant(photo), @@ -411,13 +575,13 @@ class _ViewerImageState extends State<_ViewerImage> { widget.imageUrl, fit: BoxFit.contain, gaplessPlayback: true, - loadingBuilder: (context, child, progress) => - progress == null ? child : const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), + loadingBuilder: (context, child, progress) => progress == null + ? child + : const Center(child: CircularProgressIndicator(strokeWidth: 2)), errorBuilder: (_, _, _) { // 失败 → 一次受控刷新(新签名 URL 触发重建重载)。 - if (!_autoRefreshScheduled && widget.controller.canRefresh(widget.photoId)) { + if (!_autoRefreshScheduled && + widget.controller.canRefresh(widget.photoId)) { _autoRefreshScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { widget.controller.refreshPreview(widget.photoId); @@ -444,3 +608,129 @@ class _ViewerImageState extends State<_ViewerImage> { ); } } + +/// 隐藏确认 BottomSheet(UI-03 §4 页面 20):标题 + 当前图缩略图(有可见 +/// 预览才显示)+ 影响说明 + Hide photo(主操作)/ Cancel。 +/// +/// [photo] 在打开时捕获——确认请求始终作用于该 photoId,不受查看器后续 +/// 切图/加载影响([onConfirm] 收到的即该对象)。提交中防重复;失败保留 +/// 面板并给出可重试提示;取消不请求接口。pop(true) = 已隐藏,pop()/false +/// = 取消。 +class HidePhotoConfirmSheet extends StatefulWidget { + const HidePhotoConfirmSheet({ + required this.photo, + required this.onConfirm, + super.key, + }); + + final AdminPhoto photo; + + /// 执行隐藏(如 controller.hidePhoto);抛异常 = 失败(面板保留)。 + final Future Function(AdminPhoto photo) onConfirm; + + @override + State createState() => _HidePhotoConfirmSheetState(); +} + +class _HidePhotoConfirmSheetState extends State { + bool _submitting = false; + String? _error; + + Future _submit(AppLocalizations l10n) async { + if (_submitting) return; + setState(() { + _submitting = true; + _error = null; + }); + try { + await widget.onConfirm(widget.photo); + if (!mounted) return; + Navigator.of(context).pop(true); + } on AppException catch (e) { + setState(() { + _submitting = false; + _error = l10n.messageForError(e); + }); + } on Object { + setState(() { + _submitting = false; + _error = l10n.errorGeneric; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final thumbUrl = widget.photo.preview?.thumb.url; + return SafeArea( + top: false, + child: SingleChildScrollView( + // 小屏/横屏高度不足时面板可滚动,避免压缩溢出。 + child: Padding( + padding: const EdgeInsets.fromLTRB(kSpace16, 0, kSpace16, kSpace16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.hidePhotoTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + if (thumbUrl != null) ...[ + const SizedBox(height: kSpace12), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(kRadiusBadge), + child: SizedBox( + height: 96, + width: 96, + child: PhotoThumbnail(url: thumbUrl, aspectRatio: 1), + ), + ), + ), + ], + const SizedBox(height: kSpace12), + Text( + l10n.hidePhotoBody, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (_error != null) ...[ + const SizedBox(height: kSpace8), + Text( + _error!, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: kSpace16), + FilledButton( + onPressed: _submitting ? null : () => _submit(l10n), + child: _submitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.photoHideAction), + ), + TextButton( + onPressed: _submitting + ? null + : () => Navigator.of(context).pop(false), + child: Text(l10n.cancel), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/pages/album_photos_page.dart b/apps/mobile/lib/pages/album_photos_page.dart index 30ea18c..e3bd5d3 100644 --- a/apps/mobile/lib/pages/album_photos_page.dart +++ b/apps/mobile/lib/pages/album_photos_page.dart @@ -1,6 +1,7 @@ -/// 照片管理页(UI-01 §4.3 重写):三列缩略图网格(极窄两列、平板按最小 -/// 格宽扩列)+ 状态角标(处理中/失败/隐藏/删除)+ 筛选 chips + 游标分页 -/// + 点开大图查看器。 +/// 照片管理页(UI-01 §4.3 重写;UI-03 §4 页面 17 重设计):标题「N 张 +/// 照片」+ 相册名副标题 + 筛选 chips(选中=主色蓝)+ 三列缩略图网格 +/// (极窄两列、平板按最小格宽扩列;cover 裁切 + 圆角)+ 每张至多一个 +/// 状态角标(图标+短标签)+ 游标分页 + 点开大图查看器。 /// /// 数据范围(scope):先按 all 请求(owner/admin);协作者 403 /// AUTH_FORBIDDEN 时自适应回退 own(服务端过滤,仅自己上传)——isManager @@ -9,7 +10,8 @@ /// 受控重取所在页一次(不无限重试)。 /// /// Processing/Failed 筛选为已加载页的本地过滤(服务端无对应查询参数), -/// 结果不足时自动补页(上限内),不声称全量。 +/// 结果不足时自动补页(上限内),不声称全量。标题计数为当前筛选下已 +/// 加载条目数(游标接口无总量,不伪造总数)。 library; import 'dart:math' as math; @@ -23,6 +25,7 @@ import '../core/error_codes.dart'; import '../core/error_l10n.dart'; import '../core/models/cursor_page.dart'; import '../core/session_store.dart'; +import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; import '../features/photos/models.dart'; import '../l10n/app_localizations.dart'; @@ -467,17 +470,45 @@ class _AlbumPhotosPageState extends ConsumerState { if (controller == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context).albumPhotos)), - body: AnimatedBuilder( - animation: controller, - builder: (context, _) => _AlbumPhotosBody( - controller: controller, - album: widget.album, - scrollController: _scrollController, - onOpenViewer: _openViewer, - ), - ), + // 整个 Scaffold 随 controller 重建:标题计数随加载/筛选变化。 + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context); + final count = controller.visibleItems.length; + return Scaffold( + appBar: AppBar( + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 双行标题需显式收窄字阶(AppBar 标题区高 48,默认 + // titleLarge 24 会溢出)。 + Text( + count > 0 ? l10n.photosTitle(count) : l10n.albumPhotos, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + albumDisplayTitle(widget.album, l10n.localeName), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + body: _AlbumPhotosBody( + controller: controller, + album: widget.album, + scrollController: _scrollController, + onOpenViewer: _openViewer, + ), + ); + }, ); } } @@ -530,8 +561,8 @@ class _AlbumPhotosBody extends StatelessWidget { for (final filter in PhotoGridFilter.values) Padding( padding: const EdgeInsets.only(right: kSpace8), - child: FilterChip( - label: Text(_filterLabel(l10n, filter)), + child: _PhotoFilterChip( + label: _filterLabel(l10n, filter), selected: controller.filter == filter, onSelected: (_) => controller.setFilter(filter), ), @@ -586,6 +617,15 @@ class _AlbumPhotosBody extends StatelessWidget { itemCount: visible.length + (controller.hasMore ? 1 : 0), itemBuilder: (context, index) { if (index >= visible.length) { + // 尾格:加载中转圈 / 失败可点重试(保留已加载条目)。 + if (controller.loadMoreFailed) { + return Center( + child: TextButton( + onPressed: controller.loadMore, + child: Text(l10n.loadMoreFailed), + ), + ); + } return const Center( child: SizedBox( height: 24, @@ -598,6 +638,7 @@ class _AlbumPhotosBody extends StatelessWidget { final photo = visible[index]; final loadedIndex = controller.items.indexOf(photo); return _PhotoGridTile( + key: ValueKey('grid-photo-${photo.photoId}'), photo: photo, onTap: () => onOpenViewer(loadedIndex), ); @@ -608,7 +649,9 @@ class _AlbumPhotosBody extends StatelessWidget { ); } - /// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。 + /// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。翻页 + /// 失败后停止自动重试(尾格提供显式重试入口,避免滚动时反复打失败 + /// 请求)。 void _maybeLoadMore(int index, int visibleCount) { final nearEnd = index >= visibleCount - 6; final localStarved = controller.filter.isLocal && @@ -617,7 +660,8 @@ class _AlbumPhotosBody extends StatelessWidget { if ((nearEnd || localStarved) && controller.hasMore && !controller.loadingMore && - !controller.loading) { + !controller.loading && + !controller.loadMoreFailed) { WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore()); } } @@ -632,14 +676,55 @@ class _AlbumPhotosBody extends StatelessWidget { }; } -/// 网格瓦片:1:1 缩略图(cover 裁切)+ 左上角状态角标。 +/// 筛选 chip(UI-03 §4 页面 17):选中 = 主色蓝底 + 对比文字;未选中 = +/// 表面底 + 轻分隔线边框。去 checkmark(选中态以底色表达)。 +class _PhotoFilterChip extends StatelessWidget { + const _PhotoFilterChip({ + required this.label, + required this.selected, + required this.onSelected, + }); + + final String label; + final bool selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FilterChip( + label: Text(label), + selected: selected, + showCheckmark: false, + onSelected: onSelected, + backgroundColor: theme.colorScheme.surface, + side: BorderSide( + color: selected + ? theme.colorScheme.primary + : (theme.dividerTheme.color ?? kColorDivider), + ), + selectedColor: theme.colorScheme.primary, + labelStyle: theme.textTheme.labelLarge?.copyWith( + color: selected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + padding: const EdgeInsets.symmetric(horizontal: kSpace8), + ); + } +} + +/// 网格瓦片:1:1 缩略图(cover 裁切、圆角 6)+ 左上角单个状态角标。 class _PhotoGridTile extends StatelessWidget { - const _PhotoGridTile({required this.photo, required this.onTap}); + const _PhotoGridTile({super.key, required this.photo, required this.onTap}); final AdminPhoto photo; final VoidCallback onTap; - /// 角标裁决(优先级:删除 > 失败 > 隐藏 > 处理中;就绪且公开无角标)。 + /// 缩略图圆角(方案 §4.3:4-6dp)。 + static const double _tileRadius = 6; + + /// 角标裁决(优先级:删除 > 失败 > 隐藏 > 处理中;就绪且公开无角标; + /// 每张至多一个,不叠多个冗余徽章)。 static (String, BadgeVariant)? _badge(AppLocalizations l10n, AdminPhoto p) { if (p.visibility == PhotoVisibility.deleted) { return (l10n.visibilityDeleted, BadgeVariant.neutral); @@ -662,24 +747,28 @@ class _PhotoGridTile extends StatelessWidget { final badge = _badge(l10n, photo); return InkWell( onTap: onTap, - child: Stack( - fit: StackFit.expand, - children: [ - PhotoThumbnail( - url: photo.preview?.thumb.url, - aspectRatio: 1, - ), - if (badge != null) - Positioned( - top: kSpace4, - left: kSpace4, - child: StateBadge( - variant: badge.$2, - label: badge.$1, - compact: true, - ), + borderRadius: BorderRadius.circular(_tileRadius), + child: ClipRRect( + borderRadius: BorderRadius.circular(_tileRadius), + child: Stack( + fit: StackFit.expand, + children: [ + PhotoThumbnail( + url: photo.preview?.thumb.url, + aspectRatio: 1, ), - ], + if (badge != null) + Positioned( + top: kSpace4, + left: kSpace4, + child: StateBadge( + variant: badge.$2, + label: badge.$1, + compact: true, + ), + ), + ], + ), ), ); } diff --git a/apps/mobile/lib/pages/share_qr_dialog.dart b/apps/mobile/lib/pages/share_qr_dialog.dart index e3ab439..f626d53 100644 --- a/apps/mobile/lib/pages/share_qr_dialog.dart +++ b/apps/mobile/lib/pages/share_qr_dialog.dart @@ -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 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( - (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 _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), - ), - ], - ), - ], + ), ), ), ); diff --git a/apps/mobile/test/pages/album_photo_viewer_test.dart b/apps/mobile/test/pages/album_photo_viewer_test.dart index 3dd0181..218dfe4 100644 --- a/apps/mobile/test/pages/album_photo_viewer_test.dart +++ b/apps/mobile/test/pages/album_photo_viewer_test.dart @@ -1,13 +1,16 @@ -/// 照片大图查看器 widget 测试(UI-01 §4.3)。 +/// 照片大图查看器 widget 测试(UI-01 §4.3;UI-03 §4 页面 18/20 回归)。 /// -/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + ID 诊断区)、左右切换、 +/// 覆盖:摄影深色背景 + contain 展示、信息栏(上传者/相册时区时间·尺寸· +/// 大小/状态/ID 诊断区)、左右滑动与方向键切换、临近末尾加载更多、 /// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、 -/// 删除确认(云端照片将被删除)、关闭返回网格原位置、签名 URL 失败一次 -/// 受控刷新、临近末尾加载更多。 +/// 隐藏经确认 BottomSheet(标题/缩略图/影响说明/取消不请求/photoId 打开 +/// 时捕获/成功回网格角标翻转)、删除确认(软删除文案,与隐藏确认分开)、 +/// 「⋯」更多菜单、关闭返回网格原位置、签名 URL 失败一次受控刷新。 library; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/core/api_client.dart'; @@ -18,6 +21,8 @@ import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/album_photos_page.dart'; import 'package:photo_live/pages/album_photo_viewer_page.dart'; import 'package:photo_live/ui/components/photo_thumbnail.dart'; +import 'package:photo_live/ui/components/state_badge.dart'; +import 'package:photo_live/ui/tokens.dart'; import '../helpers/fakes.dart'; @@ -64,7 +69,15 @@ void main() { return before; } - testWidgets('信息栏:上传者 / 相册时区时间 / 尺寸大小 + ID 诊断区', (tester) async { + /// 查看器子树内的查找(网格页在路由栈下方仍挂载,需按页面收窄)。 + Finder inViewer(Finder finder) => find.descendant( + of: find.byType(AlbumPhotoViewerPage), + matching: finder, + ); + + testWidgets('深色摄影背景 + contain + 信息栏(上传者/时间·尺寸·大小/状态/ID)', ( + tester, + ) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); @@ -79,10 +92,24 @@ void main() { await pumpGridAndOpen(tester, clientOf(adapter)); expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); - expect(find.text('Staff Two'), findsOneWidget); - // uploadedAt 07:00Z → 相册时区上海 15:00;尺寸 + 大小。 + // 恒深色摄影背景(不随系统主题)。 + expect( + tester.widget(inViewer(find.byType(Scaffold))).backgroundColor, + kColorPhotoBackdrop, + ); + // contain 完整展示(不裁切)。 + expect( + tester.widget(inViewer(find.byType(Image))).fit, + BoxFit.contain, + ); + // 顶栏:关闭 × + 更多 ⋯。 + expect(inViewer(find.byIcon(Icons.close)), findsOneWidget); + expect(inViewer(find.byIcon(Icons.more_vert)), findsOneWidget); + // 信息栏:上传者 / 相册时区墙钟·尺寸·大小 / 真实状态 / ID 诊断区。 + expect(find.text('Uploaded by Staff Two'), findsOneWidget); + // uploadedAt 07:00Z → 相册时区上海 15:00;4000×3000 · 121 KB。 expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget); - // ID 放诊断区域(弱化呈现)。 + expect(find.text('Status: Published'), findsOneWidget); expect(find.text('ph-info-1'), findsOneWidget); }); @@ -116,11 +143,11 @@ void main() { await pumpGridAndOpen(tester, clientOf(adapter)); // 初始第一张。 - expect(find.text('Uploader 1'), findsOneWidget); + expect(find.text('Uploaded by Uploader 1'), findsOneWidget); // 左右切换:到第二张。 await tester.drag(find.byType(PageView), const Offset(-700, 0)); await tester.pumpAndSettle(); - expect(find.text('Uploader 2'), findsOneWidget); + expect(find.text('Uploaded by Uploader 2'), findsOneWidget); // 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。 for (var i = 0; i < 20; i++) { @@ -134,7 +161,46 @@ void main() { ); }); - testWidgets('管理者:已发布照片可隐藏/删除;隐藏照片可恢复', (tester) async { + testWidgets('方向键左右切换(键盘可达性,与滑动同序列)', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson( + photoId: 'ph-k1', + uploaderName: 'Uploader 1', + preview: photoPreviewJson(), + ), + adminPhotoJson( + photoId: 'ph-k2', + uploaderName: 'Uploader 2', + preview: photoPreviewJson(), + ), + ]), + ); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + expect(find.text('Uploaded by Uploader 1'), findsOneWidget); + // → 右键下一张。 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(find.text('Uploaded by Uploader 2'), findsOneWidget); + // ← 左键回上一张;再左(已在首张)忽略不越界。 + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(find.text('Uploaded by Uploader 1'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(find.text('Uploaded by Uploader 1'), findsOneWidget); + }); + + testWidgets('管理者操作行:已发布→Hide photo+Delete;已隐藏→Publish again', ( + tester, + ) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1/photos') { return ( @@ -149,42 +215,163 @@ void main() { ]), ); } - if (o.method == 'PATCH' && o.path == '/v1/photos/ph-pub') { + throw StateError('unexpected ${o.method} ${o.path}'); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + // 已发布:Hide photo + Delete(管理者,scope=all 成功)。 + expect(find.text('Hide photo'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + expect(find.text('Publish again'), findsNothing); + + // 切到第二张(隐藏照片):Publish again(仅管理者)+ Delete。 + await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.pumpAndSettle(); + expect(find.text('Publish again'), findsOneWidget); + expect(find.text('Hide photo'), findsNothing); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('Publish again:PATCH published → 操作行翻转为 Hide photo', ( + tester, + ) async { + final adapter = FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1/photos') { return ( 200, - adminPhotoJson(photoId: 'ph-pub', visibility: 'hidden'), + cursorPageBody(>[ + adminPhotoJson( + photoId: 'ph-hid', + visibility: 'hidden', + preview: photoPreviewJson(), + ), + ]), ); } + if (o.method == 'PATCH' && o.path == '/v1/photos/ph-hid') { + return (200, adminPhotoJson(photoId: 'ph-hid')); + } throw StateError('unexpected ${o.method} ${o.path}'); }); - final client = clientOf(adapter); - await pumpGridAndOpen(tester, client); + await pumpGridAndOpen(tester, clientOf(adapter)); - // 已发布:隐藏 + 删除(管理者,scope=all 成功)。 - expect(find.text('Hide'), findsOneWidget); - expect(find.text('Delete'), findsOneWidget); - expect(find.text('Unhide'), findsNothing); + await tester.tap(find.text('Publish again')); + await tester.pumpAndSettle(); - // 切到第二张(隐藏照片):恢复显示(仅管理者)+ 删除。 - await tester.drag(find.byType(PageView), const Offset(-700, 0)); - await tester.pumpAndSettle(); - expect(find.text('Unhide'), findsOneWidget); - expect(find.text('Hide'), findsNothing); - - // 隐藏操作:PATCH visibility=hidden → 按钮翻转为恢复显示。 - await tester.drag(find.byType(PageView), const Offset(700, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Hide')); - await tester.pumpAndSettle(); final patch = adapter.requests.singleWhere( - (r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-pub', + (r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-hid', ); - expect(patch.body, {'visibility': 'hidden'}); - expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复'); - expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口'); + expect(patch.body, {'visibility': 'published'}); + expect(find.text('Hide photo'), findsOneWidget, reason: '恢复后可再隐藏'); + expect(find.text('Publish again'), findsNothing); + expect(find.text('Status: Published'), findsOneWidget); }); - testWidgets('协作者(scope=own):自己上传可隐藏/删除,不可恢复', (tester) async { + testWidgets('隐藏确认 BottomSheet:标题/缩略图/影响说明;取消不请求', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-sheet', preview: photoPreviewJson()), + ]), + ); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + // 操作行 Hide photo(OutlinedButton)打开确认面板。 + await tester.tap(find.widgetWithText(OutlinedButton, 'Hide photo')); + await tester.pumpAndSettle(); + + final sheet = find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(PhotoThumbnail), + ); + expect(find.text('Hide this photo?'), findsOneWidget); + expect( + find.text( + 'Viewers will no longer see this photo. ' + 'Authorized album managers can publish it again.', + ), + findsOneWidget, + ); + expect(sheet, findsOneWidget, reason: '有可见预览 → 面板内展示当前图缩略图'); + // 主操作 FilledButton + 取消。 + expect(find.widgetWithText(FilledButton, 'Hide photo'), findsOneWidget); + + // 取消:不发 PATCH,查看器保持。 + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + adapter.requests.any((r) => r.method == 'PATCH'), + isFalse, + reason: '取消不请求接口', + ); + expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); + }); + + testWidgets('确认隐藏:photoId 打开时捕获 → PATCH 正确对象 → 回网格角标翻转', ( + tester, + ) async { + final adapter = FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1/photos') { + return ( + 200, + cursorPageBody(>[ + adminPhotoJson( + photoId: 'ph-h1', + uploaderName: 'Uploader 1', + preview: photoPreviewJson(), + ), + adminPhotoJson( + photoId: 'ph-h2', + uploaderName: 'Uploader 2', + preview: photoPreviewJson(), + ), + ]), + ); + } + if (o.method == 'PATCH' && o.path == '/v1/photos/ph-h2') { + return (200, adminPhotoJson(photoId: 'ph-h2', visibility: 'hidden')); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + // 打开第二张(tile 1 = ph-h2)。 + await pumpGridAndOpen(tester, clientOf(adapter), tile: 1); + expect(find.text('Uploaded by Uploader 2'), findsOneWidget); + + // 操作行 Hide photo → 面板内 FilledButton Hide photo 确认。 + await tester.tap(find.widgetWithText(OutlinedButton, 'Hide photo')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Hide photo')); + await tester.pumpAndSettle(); + + // 请求作用于打开面板时捕获的照片(ph-h2),不受其他条目影响。 + final patch = adapter.requests.singleWhere((r) => r.method == 'PATCH'); + expect(patch.path, '/v1/photos/ph-h2'); + expect(patch.body, {'visibility': 'hidden'}); + // 成功:关闭面板 + 关闭查看器回网格;条目状态合并,角标翻转 Hidden。 + expect(find.byType(AlbumPhotoViewerPage), findsNothing); + // SnackBar「Hidden」成功提示(chip 同名标签在外,按容器收窄)。 + expect( + find.descendant(of: find.byType(SnackBar), matching: find.text('Hidden')), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('grid-photo-ph-h2')), + matching: find.byType(StateBadge), + ), + findsOneWidget, + reason: '网格瓦片角标翻转为 Hidden', + ); + }); + + testWidgets('协作者(scope=own):自己上传可删除,无恢复入口;无预览占位', ( + tester, + ) async { store.session = Session( token: 'tok-1', expiresAt: DateTime.utc(2099, 1, 1), @@ -214,14 +401,16 @@ void main() { await pumpGridAndOpen(tester, clientOf(adapter)); // 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。 - expect(find.text('Unhide'), findsNothing); + expect(find.text('Publish again'), findsNothing); expect(find.text('Delete'), findsOneWidget, reason: '自己上传可删除'); // 无预览(隐藏对协作者不签发)→ 状态说明 + 图片暂不可用。 expect(find.text('Image unavailable'), findsOneWidget); expect(find.text('ph-own-hidden'), findsOneWidget); }); - testWidgets('删除:确认「云端照片」→ DELETE → 条目移除', (tester) async { + testWidgets('删除确认:软删除文案 + 取消不删 + 确认 DELETE 移除条目', ( + tester, + ) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1/photos') { return ( @@ -240,10 +429,13 @@ void main() { await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); - // 确认弹窗:明确影响云端照片。 + // 确认弹窗:与隐藏确认分开,说明当前实际软删除语义。 expect(find.text('Delete this photo?'), findsOneWidget); expect( - find.text('The cloud photo will be deleted. This cannot be undone.'), + find.text( + 'This will soft-delete the photo. It can be managed by admins. ' + 'Cloud storage is not immediately purged.', + ), findsOneWidget, ); // 取消不删。 @@ -271,6 +463,54 @@ void main() { expect(find.text('No photos yet'), findsOneWidget); }); + testWidgets('「⋯」更多菜单:按权限出菜单项,删除项可走确认弹窗', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-menu', preview: photoPreviewJson()), + ]), + ); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + // 菜单项与操作行同一套权限裁决(已发布管理者:Hide photo + Delete)。 + final sheetDelete = find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Delete'), + ); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Hide photo'), + ), + findsOneWidget, + ); + expect(sheetDelete, findsOneWidget); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('Publish again'), + ), + findsNothing, + ); + + // 菜单删除 → 独立删除确认弹窗;取消不删。 + await tester.tap(sheetDelete); + await tester.pumpAndSettle(); + expect(find.text('Delete this photo?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(adapter.requests.any((r) => r.method == 'DELETE'), isFalse); + expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); + }); + testWidgets('关闭返回网格原滚动位置', (tester) async { // 默认测试画布 800 宽 → 5 列;24 条 ≈ 5 行,超出视口可滚动。 final items = List>.generate( diff --git a/apps/mobile/test/pages/album_photos_page_test.dart b/apps/mobile/test/pages/album_photos_page_test.dart index 6422f10..df0c2e5 100644 --- a/apps/mobile/test/pages/album_photos_page_test.dart +++ b/apps/mobile/test/pages/album_photos_page_test.dart @@ -1,8 +1,10 @@ -/// 照片管理网格 widget 测试(UI-01 §4.3)。 +/// 照片管理网格 widget 测试(UI-01 §4.3;UI-03 §4 页面 17 回归)。 /// -/// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/ -/// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二 -/// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。 +/// 覆盖:标题(计数 + 相册副标题)、网格列数(极窄 2 / 默认 3 / 宽屏 5)、 +/// 状态角标(处理中/失败/隐藏/删除,每张至多一个)、筛选 chips(选中= +/// 主色蓝;服务端 visibility 重载 / 本地 processing 过滤)、scope 自适应 +/// (all 403 → own + 协作者提示)、游标加载第二页、尾格失败重试、空态、 +/// 点开大图查看器。 library; import 'package:dio/dio.dart'; @@ -52,6 +54,32 @@ void main() { tester.view.devicePixelRatio = 1.0; } + testWidgets('标题:计数随筛选条目数 + 相册名做副标题', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-t1', preview: photoPreviewJson()), + adminPhotoJson(photoId: 'ph-t2', preview: photoPreviewJson()), + adminPhotoJson(photoId: 'ph-t3', preview: photoPreviewJson()), + ]), + ); + }); + await pumpPhotos(tester, clientOf(adapter)); + + // 计数 = 当前筛选下已加载条目数(游标接口无总量,不伪造总数)。 + expect(find.text('3 photos'), findsOneWidget); + // 副标题 = 相册展示名(en 回退)。 + expect(find.text('Opening Gala'), findsOneWidget); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + testWidgets('网格列数:350→2 / 390→3 / 800→5(平板按格宽扩列)', (tester) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { @@ -224,6 +252,63 @@ void main() { 'published', reason: 'Published 筛选下发服务端 visibility 参数', ); + // 选中 chip = 主色蓝底(UI-03 页面 17)。 + final chip = tester.widget( + find.widgetWithText(FilterChip, 'Published'), + ); + expect(chip.selected, isTrue); + expect(chip.selectedColor, Theme.of(tester.element(find.byType(FilterChip).first)).colorScheme.primary); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('尾格:加载更多失败 → 可点重试 → 补齐第二页', (tester) async { + setSurface(tester, const Size(390, 844)); + final page1 = List>.generate( + 20, + (i) => adminPhotoJson( + photoId: 'ph-r${(i + 1).toString().padLeft(2, '0')}', + preview: photoPreviewJson(), + ), + ); + var cursorCalls = 0; + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + final cursor = o.uri.queryParameters['cursor']; + if (cursor == null) { + return (200, cursorPageBody(page1, nextCursor: 'rc1')); + } + cursorCalls++; + // 第一次翻页失败(保留已加载条目),重试成功。 + if (cursorCalls == 1) { + return (500, errorBody(ErrorCodes.internalError)); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-r-page2', preview: photoPreviewJson()), + ]), + ); + }); + await pumpPhotos(tester, clientOf(adapter)); + + // 滚到底触发翻页 → 失败 → 已加载条目保留 + 尾格显示重试入口。 + await tester.drag(find.byType(GridView), const Offset(0, -4000)); + await tester.pumpAndSettle(); + expect(find.text('Failed to load more. Tap to retry'), findsOneWidget); + expect(find.byKey(const ValueKey('grid-photo-ph-r20')), findsOneWidget); + expect(find.byKey(const ValueKey('grid-photo-ph-r-page2')), findsNothing); + + // 点重试 → 第二页补齐(重试入口消失、第二页条目可滚动到位)。 + await tester.tap(find.text('Failed to load more. Tap to retry')); + await tester.pumpAndSettle(); + expect(find.text('Failed to load more. Tap to retry'), findsNothing); + await tester.drag(find.byType(GridView), const Offset(0, -4000)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('grid-photo-ph-r-page2')), findsOneWidget); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); diff --git a/apps/mobile/test/pages/share_qr_dialog_test.dart b/apps/mobile/test/pages/share_qr_dialog_test.dart deleted file mode 100644 index f6923f0..0000000 --- a/apps/mobile/test/pages/share_qr_dialog_test.dart +++ /dev/null @@ -1,112 +0,0 @@ -/// 分享二维码对话框 widget 测试(UI-01 §4.5 分层重构)。 -/// -/// 覆盖:分层结构(相册标题 → 白底二维码 → URL 文本 → 复制为主/浏览器 -/// 打开为次)、QrImageView 以 viewer URL({VIEWER_BASE}/a/{token})渲染、 -/// 复制按钮写入剪贴板并提示、「在浏览器中打开」经注入的替身启动器 -/// 打开 URL 并关闭对话框。 -library; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:photo_live/core/config.dart'; -import 'package:photo_live/l10n/app_localizations.dart'; -import 'package:photo_live/pages/share_qr_dialog.dart'; -import 'package:qr_flutter/qr_flutter.dart'; - -void main() { - const token = 'sharetoken000000000001'; - - Future pumpDialog( - WidgetTester tester, { - String? albumTitle, - UrlLauncher? launcher, - }) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - if (launcher != null) - urlLauncherProvider.overrideWith((ref) => launcher), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - // 复制提示走 ScaffoldMessenger:包一层 Scaffold 供 SnackBar 挂载。 - home: Scaffold( - body: Center( - child: ShareQrDialog( - publicToken: token, - albumTitle: albumTitle, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - } - - testWidgets('分层渲染:相册标题 → 二维码(data 为观众链接)→ URL 文本', - (tester) async { - await pumpDialog(tester, albumTitle: 'Opening Gala'); - - final url = ApiConfig.viewerAlbumUrl(token); - // qr_flutter 4.1 未公开 QrImageView.data(私有 _data),以同源的 - // SelectableText 文本断言 URL 构造(二维码与文本同用该局部变量)。 - expect(find.byType(QrImageView), findsOneWidget); - expect( - tester.widget(find.byType(SelectableText)).data, - url, - ); - expect(url, 'http://10.0.2.2:5175/a/$token'); - // 分层:主标题 = 相册名,副标题 = 通用标题,脚注 = 扫码/链接提示。 - expect(find.text('Opening Gala'), findsOneWidget); - expect(find.text('Share QR code'), findsOneWidget); - expect(find.text('Scan the code or open the link to view'), findsOneWidget); - }); - - testWidgets('复制按钮(主操作)→ 剪贴板收到 URL + 已复制提示', (tester) async { - final clipboardCalls = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async { - clipboardCalls.add(call); - return null; - }, - ); - - await pumpDialog(tester); - await tester.tap(find.text('Copy link')); - await tester.pumpAndSettle(); - - final url = ApiConfig.viewerAlbumUrl(token); - expect( - clipboardCalls.any( - (c) => - c.method == 'Clipboard.setData' && - (c.arguments as Map)['text'] == url, - ), - isTrue, - ); - expect(find.text('Copied'), findsOneWidget); - }); - - testWidgets('在浏览器中打开(次操作)→ 启动器收到 URL + 关闭对话框', - (tester) async { - final launchedUrls = []; - await pumpDialog( - tester, - launcher: (url) async { - launchedUrls.add(url); - return true; - }, - ); - - await tester.tap(find.text('Open in browser')); - await tester.pumpAndSettle(); - - expect(launchedUrls, [ApiConfig.viewerAlbumUrl(token)]); - expect(find.byType(ShareQrDialog), findsNothing, reason: '打开成功后关闭'); - }); -} diff --git a/apps/mobile/test/pages/share_qr_page_test.dart b/apps/mobile/test/pages/share_qr_page_test.dart new file mode 100644 index 0000000..77efd9c --- /dev/null +++ b/apps/mobile/test/pages/share_qr_page_test.dart @@ -0,0 +1,142 @@ +/// 相册分享页 widget 测试(UI-03 §4 页面 19)。 +/// +/// 覆盖:全屏页结构(标题/真实封面占位/名称/日期地点/二维码/扫码说明/ +/// 可选中链接)、链接携带正确相册 token 与 `?lang=` 语言参数、Copy link +/// 主操作写剪贴板 + 已复制提示、Open in browser 次操作经注入替身启动器 +/// 打开并退出页面、未公开相册显示「该链接目前无法公开浏览」琥珀提示 +/// (公开相册不显示)。 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/features/albums/models.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/share_qr_dialog.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../helpers/fakes.dart'; + +void main() { + const token = 'sharetoken000000000001'; + + Future pumpPage( + WidgetTester tester, { + bool publicEnabled = true, + UrlLauncher? launcher, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + if (launcher != null) + urlLauncherProvider.overrideWith((ref) => launcher), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ShareQrPage( + album: Album.fromJson( + albumJson(publicToken: token, publicEnabled: publicEnabled), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('全屏页结构:标题/封面/名称/日期地点/二维码/扫码说明/链接', ( + tester, + ) async { + await pumpPage(tester); + + // 全屏页 AppBar 标题(不再是 Dialog)。 + expect(find.byType(AppBar), findsOneWidget); + expect(find.text('Share album'), findsOneWidget); + // 真实封面位(无封面 → 同尺寸中性占位,不破图不空行)。 + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + // 名称与日期地点(en 回退:Opening Gala;上海墙钟 14:30)。 + expect(find.text('Opening Gala'), findsOneWidget); + expect( + find.text('2026/9/18 14:30 · 大宴会厅'), + findsOneWidget, + reason: '开始时间(相册时区;结束未定不伪造区间)+ 场地(展示回退链)', + ); + // 二维码真实生成 + 扫码说明 + 可选中链接。 + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text('Scan to view photos'), findsOneWidget); + // 测试环境 URL:默认 VIEWER_BASE + 正确 token + en 语言参数。 + final url = 'http://10.0.2.2:5175/a/$token?lang=en'; + expect( + tester.widget(find.byType(SelectableText)).data, + url, + reason: '二维码与复制共用同一 URL(?lang= 随 App 语言)', + ); + }); + + testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示', + (tester) async { + final clipboardCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + clipboardCalls.add(call); + return null; + }, + ); + + await pumpPage(tester); + await tester.ensureVisible(find.text('Copy link')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + + expect( + clipboardCalls.any( + (c) => + c.method == 'Clipboard.setData' && + (c.arguments as Map)['text'] == + 'http://10.0.2.2:5175/a/$token?lang=en', + ), + isTrue, + ); + expect(find.text('Copied'), findsOneWidget); + }); + + testWidgets('Open in browser(次操作)→ 启动器收到 URL + 退出分享页', + (tester) async { + final launchedUrls = []; + await pumpPage( + tester, + launcher: (url) async { + launchedUrls.add(url); + return true; + }, + ); + + await tester.ensureVisible(find.text('Open in browser')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open in browser')); + await tester.pumpAndSettle(); + + expect(launchedUrls, ['http://10.0.2.2:5175/a/$token?lang=en']); + expect(find.byType(ShareQrPage), findsNothing, reason: '打开成功后退出'); + }); + + testWidgets('未公开相册:琥珀提示链接不可公开浏览;公开相册不提示', + (tester) async { + await pumpPage(tester, publicEnabled: false); + expect( + find.text('This link is not publicly accessible yet'), + findsOneWidget, + ); + + await pumpPage(tester, publicEnabled: true); + expect( + find.text('This link is not publicly accessible yet'), + findsNothing, + reason: '公开相册不得显示不可浏览提示', + ); + }); +}