feat(mobile): UI-03 photos and share pages (17-20)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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 => '观众将不再看到这张照片。相册管理者可重新发布。';
|
||||
}
|
||||
|
||||
@@ -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": "观众将不再看到这张照片。相册管理者可重新发布。"
|
||||
}
|
||||
|
||||
@@ -219,12 +219,9 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
void _shareQr() {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => ShareQrDialog(
|
||||
publicToken: album.publicToken,
|
||||
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
||||
),
|
||||
// UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AlbumPhotoViewerPage> createState() => _AlbumPhotoViewerPageState();
|
||||
}
|
||||
|
||||
/// 「⋯」更多菜单动作(面板 pop 返回,页面侧分发执行)。
|
||||
enum _ViewerMenuAction { hide, publish, delete }
|
||||
|
||||
class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
||||
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<AlbumPhotoViewerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 方向键切换(键盘/遥控可达性;与滑动同一条 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<AlbumPhotoViewerPage> {
|
||||
photo.visibility != PhotoVisibility.deleted &&
|
||||
(widget.controller.isManager || _isOwnUpload(photo));
|
||||
|
||||
Future<void> _hide(AdminPhoto photo) async {
|
||||
/// 隐藏确认(页面 20):轻量 BottomSheet;API 调用在面板内执行(提交中
|
||||
/// 防重复、失败面板内提示可重试),photoId 打开时捕获。
|
||||
Future<void> _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<bool>(
|
||||
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<void> _unhide(AdminPhoto photo) async {
|
||||
@@ -110,9 +149,7 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
||||
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<AlbumPhotoViewerPage> {
|
||||
|
||||
Future<void> _delete(AdminPhoto photo) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// 删除确认:明确影响云端照片(与本机任务记录清理区分)。
|
||||
// 删除确认(与隐藏确认分开):说明当前实际软删除语义——标记 deleted
|
||||
// 公开端点即刻不可见,物理删除走独立运维流程(openapi deletePhoto)。
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
@@ -154,14 +192,61 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
||||
});
|
||||
}
|
||||
} 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<void> _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<AlbumPhotoViewerPage> {
|
||||
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<AlbumPhotoViewerPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部信息栏:上传者 / 时区时间 / 尺寸大小 + 操作行 + 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 = <String>[
|
||||
@@ -215,27 +309,32 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
||||
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<AlbumPhotoViewerPage> {
|
||||
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<AlbumPhotoViewerPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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<AlbumPhotoViewerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 查看器壳层:关闭按钮(安全区避让)+ 内容。
|
||||
/// 查看器壳层:顶栏(关闭 × + 更多 ⋯,白图标 + 半透明圆底,≥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<void> Function(AdminPhoto photo) onConfirm;
|
||||
|
||||
@override
|
||||
State<HidePhotoConfirmSheet> createState() => _HidePhotoConfirmSheetState();
|
||||
}
|
||||
|
||||
class _HidePhotoConfirmSheetState extends State<HidePhotoConfirmSheet> {
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AlbumPhotosPage> {
|
||||
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<bool> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<Scaffold>(inViewer(find.byType(Scaffold))).backgroundColor,
|
||||
kColorPhotoBackdrop,
|
||||
);
|
||||
// contain 完整展示(不裁切)。
|
||||
expect(
|
||||
tester.widget<Image>(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(<Map<String, dynamic>>[
|
||||
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(<Map<String, dynamic>>[
|
||||
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, <String, dynamic>{'visibility': 'hidden'});
|
||||
expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复');
|
||||
expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口');
|
||||
expect(patch.body, <String, dynamic>{'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(<Map<String, dynamic>>[
|
||||
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(<Map<String, dynamic>>[
|
||||
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, <String, dynamic>{'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(<Map<String, dynamic>>[
|
||||
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<Map<String, dynamic>>.generate(
|
||||
|
||||
@@ -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(<Map<String, dynamic>>[
|
||||
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<FilterChip>(
|
||||
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<Map<String, dynamic>>.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(<Map<String, dynamic>>[
|
||||
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();
|
||||
|
||||
@@ -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<void> 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<SelectableText>(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 = <MethodCall>[];
|
||||
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<String, dynamic>)['text'] == url,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('Copied'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('在浏览器中打开(次操作)→ 启动器收到 URL + 关闭对话框',
|
||||
(tester) async {
|
||||
final launchedUrls = <String>[];
|
||||
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: '打开成功后关闭');
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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<SelectableText>(find.byType(SelectableText)).data,
|
||||
url,
|
||||
reason: '二维码与复制共用同一 URL(?lang= 随 App 语言)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示',
|
||||
(tester) async {
|
||||
final clipboardCalls = <MethodCall>[];
|
||||
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<String, dynamic>)['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 = <String>[];
|
||||
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: '公开相册不得显示不可浏览提示',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user