feat(mobile): UI-01 album cards, photo grid and full viewer
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -442,10 +442,15 @@ class ApiClient {
|
|||||||
|
|
||||||
/// operationId `listAlbumPhotos`
|
/// operationId `listAlbumPhotos`
|
||||||
/// (GET /albums/{albumId}/photos,管理列表,全状态可见)。
|
/// (GET /albums/{albumId}/photos,管理列表,全状态可见)。
|
||||||
|
///
|
||||||
|
/// [scope](UI-01):缺省/null=服务端默认 all(仅 owner/admin,协作者
|
||||||
|
/// 403 AUTH_FORBIDDEN);[AlbumPhotoScope.own]=仅自己上传(任何有效成员,
|
||||||
|
/// 服务端过滤)。页面层据此做「all 403 → own」自适应回退。
|
||||||
Future<CursorPage<AdminPhoto>> listAlbumPhotos(
|
Future<CursorPage<AdminPhoto>> listAlbumPhotos(
|
||||||
String albumId, {
|
String albumId, {
|
||||||
PhotoVisibility? visibility,
|
PhotoVisibility? visibility,
|
||||||
String? categoryId,
|
String? categoryId,
|
||||||
|
AlbumPhotoScope? scope,
|
||||||
String? cursor,
|
String? cursor,
|
||||||
int? limit,
|
int? limit,
|
||||||
}) =>
|
}) =>
|
||||||
@@ -457,6 +462,7 @@ class ApiClient {
|
|||||||
extraQuery: {
|
extraQuery: {
|
||||||
if (visibility != null) 'visibility': visibility.wire,
|
if (visibility != null) 'visibility': visibility.wire,
|
||||||
'categoryId': ?categoryId,
|
'categoryId': ?categoryId,
|
||||||
|
if (scope != null) 'scope': scope.wire,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ class Album extends AlbumSummary {
|
|||||||
required super.createdAt,
|
required super.createdAt,
|
||||||
required super.updatedAt,
|
required super.updatedAt,
|
||||||
required super.statsSyncedAt,
|
required super.statsSyncedAt,
|
||||||
|
super.cover,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Album.fromJson(Map<String, dynamic> json) => Album(
|
factory Album.fromJson(Map<String, dynamic> json) => Album(
|
||||||
@@ -265,6 +266,9 @@ class Album extends AlbumSummary {
|
|||||||
(dynamic e) => Category.fromJson(e as Map<String, dynamic>),
|
(dynamic e) => Category.fromJson(e as Map<String, dynamic>),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
cover: json['cover'] == null
|
||||||
|
? null
|
||||||
|
: SignedUrl.fromJson(json['cover'] as Map<String, dynamic>),
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
publicToken: json['publicToken'] as String,
|
publicToken: json['publicToken'] as String,
|
||||||
ownerId: json['ownerId'] as String,
|
ownerId: json['ownerId'] as String,
|
||||||
|
|||||||
@@ -177,5 +177,22 @@
|
|||||||
"uploadAction": "Upload",
|
"uploadAction": "Upload",
|
||||||
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
|
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
|
||||||
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
||||||
"uploadPickNone": "No photos selected"
|
"uploadPickNone": "No photos selected",
|
||||||
|
"albumManagePhotos": "Manage photos",
|
||||||
|
"albumTakeFirstPhoto": "Take the first photo",
|
||||||
|
"albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now",
|
||||||
|
"albumRecentPhotos": "Recent uploads",
|
||||||
|
"photoFilterAll": "All",
|
||||||
|
"photoFilterFailed": "Failed",
|
||||||
|
"photosScopeOwnHint": "Collaborator view: only photos you uploaded are shown",
|
||||||
|
"photosEmptyFiltered": "No photos match this filter",
|
||||||
|
"photoHide": "Hide",
|
||||||
|
"photoUnhide": "Unhide",
|
||||||
|
"photoDeleteConfirmTitle": "Delete this photo?",
|
||||||
|
"photoDeleteConfirmBody": "The cloud photo will be deleted. This cannot be undone.",
|
||||||
|
"photoHiddenToast": "Hidden",
|
||||||
|
"photoUnhiddenToast": "Unhidden",
|
||||||
|
"photoDeletedToast": "Deleted",
|
||||||
|
"photoReload": "Reload",
|
||||||
|
"photoPreviewUnavailable": "Image unavailable"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1129,6 +1129,108 @@ abstract class AppLocalizations {
|
|||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
/// **'未选择照片'**
|
/// **'未选择照片'**
|
||||||
String get uploadPickNone;
|
String get uploadPickNone;
|
||||||
|
|
||||||
|
/// No description provided for @albumManagePhotos.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'管理照片'**
|
||||||
|
String get albumManagePhotos;
|
||||||
|
|
||||||
|
/// No description provided for @albumTakeFirstPhoto.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'拍摄第一张照片'**
|
||||||
|
String get albumTakeFirstPhoto;
|
||||||
|
|
||||||
|
/// No description provided for @albumUploadDisabledHint.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'相册已关闭上传,暂不能新增照片'**
|
||||||
|
String get albumUploadDisabledHint;
|
||||||
|
|
||||||
|
/// No description provided for @albumRecentPhotos.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'最近上传'**
|
||||||
|
String get albumRecentPhotos;
|
||||||
|
|
||||||
|
/// No description provided for @photoFilterAll.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'全部'**
|
||||||
|
String get photoFilterAll;
|
||||||
|
|
||||||
|
/// No description provided for @photoFilterFailed.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'失败'**
|
||||||
|
String get photoFilterFailed;
|
||||||
|
|
||||||
|
/// No description provided for @photosScopeOwnHint.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'协作者视图:仅显示你上传的照片'**
|
||||||
|
String get photosScopeOwnHint;
|
||||||
|
|
||||||
|
/// No description provided for @photosEmptyFiltered.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'当前筛选下暂无照片'**
|
||||||
|
String get photosEmptyFiltered;
|
||||||
|
|
||||||
|
/// No description provided for @photoHide.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'隐藏'**
|
||||||
|
String get photoHide;
|
||||||
|
|
||||||
|
/// No description provided for @photoUnhide.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'恢复显示'**
|
||||||
|
String get photoUnhide;
|
||||||
|
|
||||||
|
/// No description provided for @photoDeleteConfirmTitle.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'删除这张照片?'**
|
||||||
|
String get photoDeleteConfirmTitle;
|
||||||
|
|
||||||
|
/// No description provided for @photoDeleteConfirmBody.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'云端照片将被删除,删除后不可恢复。'**
|
||||||
|
String get photoDeleteConfirmBody;
|
||||||
|
|
||||||
|
/// No description provided for @photoHiddenToast.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'已隐藏'**
|
||||||
|
String get photoHiddenToast;
|
||||||
|
|
||||||
|
/// No description provided for @photoUnhiddenToast.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'已恢复显示'**
|
||||||
|
String get photoUnhiddenToast;
|
||||||
|
|
||||||
|
/// No description provided for @photoDeletedToast.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'已删除'**
|
||||||
|
String get photoDeletedToast;
|
||||||
|
|
||||||
|
/// No description provided for @photoReload.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'重新加载'**
|
||||||
|
String get photoReload;
|
||||||
|
|
||||||
|
/// No description provided for @photoPreviewUnavailable.
|
||||||
|
///
|
||||||
|
/// In zh, this message translates to:
|
||||||
|
/// **'图片暂不可用'**
|
||||||
|
String get photoPreviewUnavailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -551,4 +551,58 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get uploadPickNone => 'No photos selected';
|
String get uploadPickNone => 'No photos selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumManagePhotos => 'Manage photos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumTakeFirstPhoto => 'Take the first photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumUploadDisabledHint =>
|
||||||
|
'Uploads are off for this album. New photos cannot be added right now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumRecentPhotos => 'Recent uploads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoFilterAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoFilterFailed => 'Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photosScopeOwnHint =>
|
||||||
|
'Collaborator view: only photos you uploaded are shown';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photosEmptyFiltered => 'No photos match this filter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoHide => 'Hide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoUnhide => 'Unhide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoDeleteConfirmTitle => 'Delete this photo?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoDeleteConfirmBody =>
|
||||||
|
'The cloud photo will be deleted. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoHiddenToast => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoUnhiddenToast => 'Unhidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoDeletedToast => 'Deleted';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoReload => 'Reload';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoPreviewUnavailable => 'Image unavailable';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -535,4 +535,55 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get uploadPickNone => '未选择照片';
|
String get uploadPickNone => '未选择照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumManagePhotos => '管理照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumTakeFirstPhoto => '拍摄第一张照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumUploadDisabledHint => '相册已关闭上传,暂不能新增照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumRecentPhotos => '最近上传';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoFilterAll => '全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoFilterFailed => '失败';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photosScopeOwnHint => '协作者视图:仅显示你上传的照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photosEmptyFiltered => '当前筛选下暂无照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoHide => '隐藏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoUnhide => '恢复显示';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoDeleteConfirmTitle => '删除这张照片?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoDeleteConfirmBody => '云端照片将被删除,删除后不可恢复。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoHiddenToast => '已隐藏';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoUnhiddenToast => '已恢复显示';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoDeletedToast => '已删除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoReload => '重新加载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get photoPreviewUnavailable => '图片暂不可用';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,5 +177,22 @@
|
|||||||
"uploadAction": "上传",
|
"uploadAction": "上传",
|
||||||
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
|
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
|
||||||
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
||||||
"uploadPickNone": "未选择照片"
|
"uploadPickNone": "未选择照片",
|
||||||
|
"albumManagePhotos": "管理照片",
|
||||||
|
"albumTakeFirstPhoto": "拍摄第一张照片",
|
||||||
|
"albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片",
|
||||||
|
"albumRecentPhotos": "最近上传",
|
||||||
|
"photoFilterAll": "全部",
|
||||||
|
"photoFilterFailed": "失败",
|
||||||
|
"photosScopeOwnHint": "协作者视图:仅显示你上传的照片",
|
||||||
|
"photosEmptyFiltered": "当前筛选下暂无照片",
|
||||||
|
"photoHide": "隐藏",
|
||||||
|
"photoUnhide": "恢复显示",
|
||||||
|
"photoDeleteConfirmTitle": "删除这张照片?",
|
||||||
|
"photoDeleteConfirmBody": "云端照片将被删除,删除后不可恢复。",
|
||||||
|
"photoHiddenToast": "已隐藏",
|
||||||
|
"photoUnhiddenToast": "已恢复显示",
|
||||||
|
"photoDeletedToast": "已删除",
|
||||||
|
"photoReload": "重新加载",
|
||||||
|
"photoPreviewUnavailable": "图片暂不可用"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签)
|
/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 →
|
||||||
/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理
|
/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/
|
||||||
/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。
|
/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传
|
||||||
/// 编辑与分类变更后刷新本地详情并同步全局相册列表。
|
/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
|
||||||
|
///
|
||||||
|
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
|
||||||
|
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
|
||||||
|
/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -14,15 +18,23 @@ import '../core/error_l10n.dart';
|
|||||||
import '../features/albums/album_format.dart';
|
import '../features/albums/album_format.dart';
|
||||||
import '../features/albums/models.dart';
|
import '../features/albums/models.dart';
|
||||||
import '../features/albums/providers.dart';
|
import '../features/albums/providers.dart';
|
||||||
|
import '../features/photos/models.dart';
|
||||||
import '../features/uploads/enqueue.dart';
|
import '../features/uploads/enqueue.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../ui/components/photo_thumbnail.dart';
|
||||||
|
import '../ui/components/section_header.dart';
|
||||||
|
import '../ui/components/state_badge.dart';
|
||||||
|
import '../ui/tokens.dart';
|
||||||
import 'album_form_page.dart';
|
import 'album_form_page.dart';
|
||||||
import 'album_members_page.dart';
|
import 'album_members_page.dart';
|
||||||
import 'album_photos_page.dart';
|
import 'album_photos_page.dart';
|
||||||
import 'capture_page.dart';
|
import 'capture_page.dart';
|
||||||
import 'share_qr_dialog.dart';
|
import 'share_qr_dialog.dart';
|
||||||
|
|
||||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。
|
/// 详情页照片预览条取数上限(方案 §4.2「3-5 张缩略图」)。
|
||||||
|
const int _kPreviewStripLimit = 5;
|
||||||
|
|
||||||
|
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
|
||||||
class AlbumDetailPage extends ConsumerStatefulWidget {
|
class AlbumDetailPage extends ConsumerStatefulWidget {
|
||||||
const AlbumDetailPage({required this.albumId, super.key});
|
const AlbumDetailPage({required this.albumId, super.key});
|
||||||
|
|
||||||
@@ -40,6 +52,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _errorCode;
|
String? _errorCode;
|
||||||
|
|
||||||
|
/// 最近上传预览条(协作者为「自己上传」,服务端过滤)。
|
||||||
|
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
|
||||||
|
bool _photosLoading = true;
|
||||||
|
|
||||||
|
/// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。
|
||||||
|
/// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。
|
||||||
|
bool _isManager = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -61,12 +81,12 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
final album = await ref.read(apiClientProvider).getAlbum(
|
final album = await ref.read(apiClientProvider).getAlbum(
|
||||||
widget.albumId,
|
widget.albumId,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_album = album;
|
_album = album;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
}
|
await _loadPhotoPreview();
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -84,6 +104,63 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己
|
||||||
|
/// 上传)。非 403 失败不阻塞详情页(入口退化为「管理照片」按钮)。
|
||||||
|
Future<void> _loadPhotoPreview() async {
|
||||||
|
final album = _album;
|
||||||
|
if (album == null) return;
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final page = await api.listAlbumPhotos(
|
||||||
|
album.id,
|
||||||
|
scope: AlbumPhotoScope.all,
|
||||||
|
limit: _kPreviewStripLimit,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_recentPhotos = page.items;
|
||||||
|
_photosLoading = false;
|
||||||
|
_isManager = true;
|
||||||
|
});
|
||||||
|
} on AppException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (e.code != ErrorCodes.authForbidden) {
|
||||||
|
setState(() {
|
||||||
|
_recentPhotos = const <AdminPhoto>[];
|
||||||
|
_photosLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final ownPage = await api.listAlbumPhotos(
|
||||||
|
album.id,
|
||||||
|
scope: AlbumPhotoScope.own,
|
||||||
|
limit: _kPreviewStripLimit,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_recentPhotos = ownPage.items;
|
||||||
|
_photosLoading = false;
|
||||||
|
_isManager = false;
|
||||||
|
});
|
||||||
|
} on AppException {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_recentPhotos = const <AdminPhoto>[];
|
||||||
|
_photosLoading = false;
|
||||||
|
_isManager = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_recentPhotos = const <AdminPhoto>[];
|
||||||
|
_photosLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 编辑成功后:详情重载 + 全局列表同步。
|
/// 编辑成功后:详情重载 + 全局列表同步。
|
||||||
Future<void> _openEdit() async {
|
Future<void> _openEdit() async {
|
||||||
final album = _album;
|
final album = _album;
|
||||||
@@ -118,6 +195,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
builder: (_) => AlbumPhotosPage(album: album),
|
builder: (_) => AlbumPhotosPage(album: album),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// 管理页内可能发生隐藏/删除,回来后刷新预览条。
|
||||||
|
await _loadPhotoPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _shareQr() {
|
void _shareQr() {
|
||||||
@@ -129,7 +208,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
|
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
|
||||||
|
|
||||||
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
||||||
Future<String?> _currentAccountId() async {
|
Future<String?> _currentAccountId() async {
|
||||||
@@ -137,36 +216,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
return session?.account;
|
return session?.account;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openUploadSheet() {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
return showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (sheetContext) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.photo_camera_outlined),
|
|
||||||
title: Text(l10n.uploadActionCapture),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(sheetContext).pop();
|
|
||||||
_openCapture();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.photo_library_outlined),
|
|
||||||
title: Text(l10n.uploadActionPick),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(sheetContext).pop();
|
|
||||||
_pickFromGallery();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
|
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
|
||||||
Future<void> _openCapture() async {
|
Future<void> _openCapture() async {
|
||||||
final album = _album;
|
final album = _album;
|
||||||
@@ -180,7 +229,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
target: CaptureTarget(
|
target: CaptureTarget(
|
||||||
accountId: account,
|
accountId: account,
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumTitle: _titleOf(album),
|
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
||||||
categories: album.categories,
|
categories: album.categories,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -272,16 +321,13 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final album = _album;
|
final album = _album;
|
||||||
final title = album == null ? l10n.albumDetail : _titleOf(album);
|
final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(title)),
|
appBar: AppBar(title: Text(title)),
|
||||||
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
|
bottomNavigationBar: album == null ? null : _UploadBar(
|
||||||
floatingActionButton: album == null
|
uploadEnabled: album.uploadEnabled,
|
||||||
? null
|
onCapture: _openCapture,
|
||||||
: FloatingActionButton.extended(
|
onPick: _pickFromGallery,
|
||||||
onPressed: _openUploadSheet,
|
|
||||||
icon: const Icon(Icons.photo_camera_outlined),
|
|
||||||
label: Text(l10n.uploadAction),
|
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
@@ -292,21 +338,32 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
)
|
)
|
||||||
: SingleChildScrollView(
|
: SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(kSpace16),
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_InfoCard(album: album),
|
_CompactHeader(album: album),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: kSpace16),
|
||||||
_ActionsRow(
|
_StatusSection(album: album),
|
||||||
|
const SizedBox(height: kSpace16),
|
||||||
|
_PhotoEntrySection(
|
||||||
|
album: album,
|
||||||
|
photos: _recentPhotos,
|
||||||
|
loading: _photosLoading,
|
||||||
|
isManager: _isManager,
|
||||||
|
onOpenPhotos: _openPhotos,
|
||||||
|
onTakeFirst: _openCapture,
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace16),
|
||||||
|
_SecondaryActions(
|
||||||
|
showManage: _isManager,
|
||||||
onEdit: _openEdit,
|
onEdit: _openEdit,
|
||||||
onMembers: _openMembers,
|
onMembers: _openMembers,
|
||||||
onCategories: _scrollToCategories,
|
onCategories: _scrollToCategories,
|
||||||
onShareQr: _shareQr,
|
onShareQr: _shareQr,
|
||||||
onPhotos: _openPhotos,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
if (_isManager) ...[
|
||||||
|
const SizedBox(height: kSpace16),
|
||||||
_CategoriesSection(
|
_CategoriesSection(
|
||||||
key: _categoriesKey,
|
key: _categoriesKey,
|
||||||
album: album,
|
album: album,
|
||||||
@@ -314,20 +371,328 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|||||||
onEdit: (c) => _openCategoryDialog(c),
|
onEdit: (c) => _openCategoryDialog(c),
|
||||||
onDelete: _deleteCategory,
|
onDelete: _deleteCategory,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: kSpace32),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static String _titleOf(Album album) {
|
/// 紧凑头部:封面缩略图(16:9,最高 200)+ 标题 + 时间/场地单行。
|
||||||
for (final t in album.translations) {
|
class _CompactHeader extends StatelessWidget {
|
||||||
if (t.locale == album.defaultLocale) return t.title;
|
const _CompactHeader({required this.album});
|
||||||
|
|
||||||
|
final Album album;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final venue = albumDisplayVenue(album, l10n.localeName);
|
||||||
|
final start =
|
||||||
|
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
||||||
|
final end = album.endAt == null
|
||||||
|
? l10n.notSet
|
||||||
|
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
||||||
|
final meta = venue == null
|
||||||
|
? '$start ~ $end (${album.timezone})'
|
||||||
|
: '$start ~ $end (${album.timezone}) · $venue';
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// 16:9 封面,最高 200(超出按高度封顶,宽度同步收窄)。
|
||||||
|
final height = constraints.maxWidth * 9 / 16;
|
||||||
|
final capped = height > 200.0;
|
||||||
|
final h = capped ? 200.0 : height;
|
||||||
|
final w = capped ? h * 16 / 9 : constraints.maxWidth;
|
||||||
|
return Center(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
child: PhotoThumbnail(
|
||||||
|
url: album.cover?.url,
|
||||||
|
aspectRatio: w / h,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace12),
|
||||||
|
Text(
|
||||||
|
albumDisplayTitle(album, l10n.localeName),
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace4),
|
||||||
|
Text(
|
||||||
|
meta,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (album.translations.isNotEmpty) return album.translations.first.title;
|
}
|
||||||
return album.id;
|
|
||||||
|
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
|
||||||
|
class _StatusSection extends StatelessWidget {
|
||||||
|
const _StatusSection({required this.album});
|
||||||
|
|
||||||
|
final Album album;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
StateBadge(
|
||||||
|
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
|
||||||
|
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
|
||||||
|
),
|
||||||
|
const SizedBox(width: kSpace8),
|
||||||
|
StateBadge(
|
||||||
|
variant:
|
||||||
|
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
|
||||||
|
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
|
||||||
|
),
|
||||||
|
if (album.status == AlbumStatus.offline) ...[
|
||||||
|
const SizedBox(width: kSpace8),
|
||||||
|
StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照片入口:有照片 → 预览条(≤5 张)+「管理照片」;无照片 →「拍摄第
|
||||||
|
/// 一张照片」引导(上传关闭时禁用,由底部栏说明原因)。
|
||||||
|
class _PhotoEntrySection extends StatelessWidget {
|
||||||
|
const _PhotoEntrySection({
|
||||||
|
required this.album,
|
||||||
|
required this.photos,
|
||||||
|
required this.loading,
|
||||||
|
required this.isManager,
|
||||||
|
required this.onOpenPhotos,
|
||||||
|
required this.onTakeFirst,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Album album;
|
||||||
|
final List<AdminPhoto> photos;
|
||||||
|
final bool loading;
|
||||||
|
final bool isManager;
|
||||||
|
final VoidCallback onOpenPhotos;
|
||||||
|
final VoidCallback onTakeFirst;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SectionHeader(
|
||||||
|
title: l10n.albumPhotos,
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: onOpenPhotos,
|
||||||
|
child: Text(l10n.albumManagePhotos),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace8),
|
||||||
|
if (loading)
|
||||||
|
const SizedBox(
|
||||||
|
height: 96,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (photos.isNotEmpty)
|
||||||
|
SizedBox(
|
||||||
|
height: 96,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: photos.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: kSpace8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final photo = photos[index];
|
||||||
|
return SizedBox(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
child: PhotoThumbnail(
|
||||||
|
url: photo.preview?.thumb.url,
|
||||||
|
aspectRatio: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.photo_camera_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: kSpace8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.albumPhotosEmpty,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: kSpace8),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: album.uploadEnabled ? onTakeFirst : null,
|
||||||
|
icon: const Icon(Icons.photo_camera_outlined),
|
||||||
|
label: Text(l10n.albumTakeFirstPhoto),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 次级管理入口(ListTile 次强调,不再与主操作同级):编辑 / 成员 / 分类
|
||||||
|
/// (滚动定位)/ 分享二维码。管理类入口仅创建者/管理员可见;分享对全体
|
||||||
|
/// 成员开放(publicToken 本身对成员可见)。
|
||||||
|
class _SecondaryActions extends StatelessWidget {
|
||||||
|
const _SecondaryActions({
|
||||||
|
required this.showManage,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onMembers,
|
||||||
|
required this.onCategories,
|
||||||
|
required this.onShareQr,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool showManage;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onMembers;
|
||||||
|
final VoidCallback onCategories;
|
||||||
|
final VoidCallback onShareQr;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (showManage) ...[
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context).actionEdit),
|
||||||
|
onTap: onEdit,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.group_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context).albumMembers),
|
||||||
|
onTap: onMembers,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.category_outlined),
|
||||||
|
title: Text(AppLocalizations.of(context).albumCategories),
|
||||||
|
onTap: onCategories,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_2),
|
||||||
|
title: Text(AppLocalizations.of(context).shareQrTitle),
|
||||||
|
onTap: onShareQr,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
|
||||||
|
/// 关闭时给出原因说明并禁用两个入口。
|
||||||
|
class _UploadBar extends StatelessWidget {
|
||||||
|
const _UploadBar({
|
||||||
|
required this.uploadEnabled,
|
||||||
|
required this.onCapture,
|
||||||
|
required this.onPick,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool uploadEnabled;
|
||||||
|
final VoidCallback onCapture;
|
||||||
|
final VoidCallback onPick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (!uploadEnabled)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
kSpace16,
|
||||||
|
kSpace8,
|
||||||
|
kSpace16,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: kSpace4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.albumUploadDisabledHint,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
kSpace16,
|
||||||
|
kSpace8,
|
||||||
|
kSpace16,
|
||||||
|
kSpace12,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: uploadEnabled ? onCapture : null,
|
||||||
|
icon: const Icon(Icons.photo_camera_outlined),
|
||||||
|
label: Text(l10n.uploadActionCapture),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: kSpace12),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: uploadEnabled ? onPick : null,
|
||||||
|
icon: const Icon(Icons.photo_library_outlined),
|
||||||
|
label: Text(l10n.uploadActionPick),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,191 +725,6 @@ class _ErrorRetryView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 信息卡:标题 + 状态、默认语言内容(描述/场地/地址)、时区时间区间、
|
|
||||||
/// 公开令牌、四开关标签。
|
|
||||||
class _InfoCard extends StatelessWidget {
|
|
||||||
const _InfoCard({required this.album});
|
|
||||||
|
|
||||||
final Album album;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final translation = album.translations
|
|
||||||
.where((t) => t.locale == album.defaultLocale)
|
|
||||||
.firstOrNull;
|
|
||||||
final start =
|
|
||||||
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
|
||||||
final end = album.endAt == null
|
|
||||||
? l10n.notSet
|
|
||||||
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
translation?.title ?? album.id,
|
|
||||||
style: theme.textTheme.titleLarge
|
|
||||||
?.copyWith(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_AlbumStatusBadge(status: album.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if ((translation?.description ?? '').trim().isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(translation!.description!),
|
|
||||||
],
|
|
||||||
if ((translation?.venue ?? '').trim().isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.place_outlined, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(child: Text(translation!.venue!)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if ((translation?.address ?? '').trim().isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.signpost_outlined, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(child: Text(translation!.address!)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.schedule, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(child: Text('$start ~ $end (${album.timezone})')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.link, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
album.publicToken,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(
|
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 4,
|
|
||||||
children: [
|
|
||||||
_switchTag(context, l10n.tagUpload, album.uploadEnabled),
|
|
||||||
_switchTag(context, l10n.tagPublic, album.publicEnabled),
|
|
||||||
_switchTag(
|
|
||||||
context,
|
|
||||||
l10n.tagDownload,
|
|
||||||
album.downloadEnabled,
|
|
||||||
),
|
|
||||||
_switchTag(
|
|
||||||
context,
|
|
||||||
l10n.tagOriginal,
|
|
||||||
album.originalDownloadEnabled,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _switchTag(BuildContext context, String label, bool on) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: on
|
|
||||||
? theme.colorScheme.primaryContainer
|
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: on
|
|
||||||
? theme.colorScheme.onPrimaryContainer
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 动作行:编辑 / 成员管理 / 分类管理(滚动定位) / 分享二维码 / 照片管理。
|
|
||||||
class _ActionsRow extends StatelessWidget {
|
|
||||||
const _ActionsRow({
|
|
||||||
required this.onEdit,
|
|
||||||
required this.onMembers,
|
|
||||||
required this.onCategories,
|
|
||||||
required this.onShareQr,
|
|
||||||
required this.onPhotos,
|
|
||||||
});
|
|
||||||
|
|
||||||
final VoidCallback onEdit;
|
|
||||||
final VoidCallback onMembers;
|
|
||||||
final VoidCallback onCategories;
|
|
||||||
final VoidCallback onShareQr;
|
|
||||||
final VoidCallback onPhotos;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
return Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: onEdit,
|
|
||||||
icon: const Icon(Icons.edit_outlined),
|
|
||||||
label: Text(l10n.actionEdit),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: onMembers,
|
|
||||||
icon: const Icon(Icons.group_outlined),
|
|
||||||
label: Text(l10n.albumMembers),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: onCategories,
|
|
||||||
icon: const Icon(Icons.category_outlined),
|
|
||||||
label: Text(l10n.albumCategories),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: onShareQr,
|
|
||||||
icon: const Icon(Icons.qr_code_2),
|
|
||||||
label: Text(l10n.shareQrTitle),
|
|
||||||
),
|
|
||||||
FilledButton.tonalIcon(
|
|
||||||
onPressed: onPhotos,
|
|
||||||
icon: const Icon(Icons.photo_outlined),
|
|
||||||
label: Text(l10n.albumPhotos),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。
|
/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。
|
||||||
class _CategoriesSection extends StatelessWidget {
|
class _CategoriesSection extends StatelessWidget {
|
||||||
const _CategoriesSection({
|
const _CategoriesSection({
|
||||||
@@ -594,6 +774,7 @@ class _CategoriesSection extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Card(
|
Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final (i, category) in album.categories.indexed) ...[
|
for (final (i, category) in album.categories.indexed) ...[
|
||||||
@@ -633,37 +814,6 @@ class _CategoriesSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 状态徽标(详情信息卡用)。
|
|
||||||
class _AlbumStatusBadge extends StatelessWidget {
|
|
||||||
const _AlbumStatusBadge({required this.status});
|
|
||||||
|
|
||||||
final AlbumStatus status;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final active = status == AlbumStatus.active;
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: active
|
|
||||||
? theme.colorScheme.secondaryContainer
|
|
||||||
: theme.colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
active ? l10n.albumStatusActive : l10n.albumStatusOffline,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: active
|
|
||||||
? theme.colorScheme.onSecondaryContainer
|
|
||||||
: theme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。
|
/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。
|
||||||
///
|
///
|
||||||
/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。
|
/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/// 照片大图查看器(UI-01 §4.3):已加载照片间左右切换、临近边缘加载
|
||||||
|
/// 更多、contain 完整展示;底部信息栏(上传者 / 相册时区时间 / 尺寸大
|
||||||
|
/// 小,photoId 放诊断区域);隐藏/恢复/删除单张操作(权限校验 + 删除
|
||||||
|
/// 确认「云端照片将被删除」);签名 URL 过期受控刷新一次。
|
||||||
|
///
|
||||||
|
/// 关闭后回到原网格位置(网格页状态与 ScrollController 在路由下保留)。
|
||||||
|
/// 恢复发布仅 owner/admin([PhotoGridController.isManager]);隐藏/删除
|
||||||
|
/// 为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/app_exception.dart';
|
||||||
|
import '../core/error_l10n.dart';
|
||||||
|
import '../features/albums/album_format.dart';
|
||||||
|
import '../features/albums/models.dart';
|
||||||
|
import '../features/photos/models.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../ui/components/state_badge.dart';
|
||||||
|
import '../ui/tokens.dart';
|
||||||
|
import 'album_photos_page.dart';
|
||||||
|
|
||||||
|
/// 大图查看器(网格页 push 进入,共享 [controller] 的已加载条目)。
|
||||||
|
class AlbumPhotoViewerPage extends StatefulWidget {
|
||||||
|
const AlbumPhotoViewerPage({
|
||||||
|
required this.controller,
|
||||||
|
required this.album,
|
||||||
|
required this.initialIndex,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PhotoGridController controller;
|
||||||
|
final Album album;
|
||||||
|
|
||||||
|
/// 初始照片(controller.items 内的下标)。
|
||||||
|
final int initialIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AlbumPhotoViewerPage> createState() => _AlbumPhotoViewerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
||||||
|
late final PageController _pageController;
|
||||||
|
late int _index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_index = widget.initialIndex.clamp(0, math.max(widget.controller.items.length - 1, 0));
|
||||||
|
_pageController = PageController(initialPage: _index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageChanged(int page) {
|
||||||
|
setState(() => _index = page);
|
||||||
|
final controller = widget.controller;
|
||||||
|
// 临近已加载末尾 → 预取下一页(loadMore 自带并发/末页守卫)。
|
||||||
|
if (page >= controller.items.length - 3 &&
|
||||||
|
controller.hasMore &&
|
||||||
|
!controller.loadingMore &&
|
||||||
|
!controller.loading) {
|
||||||
|
controller.loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isOwnUpload(AdminPhoto photo) =>
|
||||||
|
widget.controller.selfAccount != null &&
|
||||||
|
photo.uploader.account == widget.controller.selfAccount;
|
||||||
|
|
||||||
|
/// 隐藏(published→hidden):管理者或自己上传。
|
||||||
|
bool _canHide(AdminPhoto photo) =>
|
||||||
|
photo.visibility == PhotoVisibility.published &&
|
||||||
|
(widget.controller.isManager || _isOwnUpload(photo));
|
||||||
|
|
||||||
|
/// 恢复显示(hidden→published):仅管理者(服务端同口径)。
|
||||||
|
bool _canUnhide(AdminPhoto photo) =>
|
||||||
|
photo.visibility == PhotoVisibility.hidden && widget.controller.isManager;
|
||||||
|
|
||||||
|
/// 删除:管理者或自己上传(未删除态)。
|
||||||
|
bool _canDelete(AdminPhoto photo) =>
|
||||||
|
photo.visibility != PhotoVisibility.deleted &&
|
||||||
|
(widget.controller.isManager || _isOwnUpload(photo));
|
||||||
|
|
||||||
|
Future<void> _hide(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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unhide(AdminPhoto photo) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await widget.controller.unhidePhoto(photo);
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l10n.photoUnhiddenToast)));
|
||||||
|
} on AppException catch (e) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.messageForError(e))),
|
||||||
|
);
|
||||||
|
} on Object {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(AdminPhoto photo) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
// 删除确认:明确影响云端照片(与本机任务记录清理区分)。
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(l10n.photoDeleteConfirmTitle),
|
||||||
|
content: Text(l10n.photoDeleteConfirmBody),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(l10n.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await widget.controller.deletePhoto(photo);
|
||||||
|
widget.controller.removeById(photo.photoId);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l10n.photoDeletedToast)));
|
||||||
|
// 当前照片已删除且列表为空 → 回网格;否则夹取到有效页。
|
||||||
|
if (widget.controller.items.isEmpty) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_index = _index.clamp(0, widget.controller.items.length - 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} on AppException catch (e) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.messageForError(e))),
|
||||||
|
);
|
||||||
|
} on Object {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 整个 Scaffold(含信息栏)随 controller 重建:隐藏/恢复/刷新合并
|
||||||
|
// 后操作行与状态需同步更新(仅重建 body 会留下过期的操作入口)。
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: widget.controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final items = widget.controller.items;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: items.isEmpty
|
||||||
|
? const _ViewerShell(child: SizedBox.shrink())
|
||||||
|
: _ViewerShell(
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
itemCount: items.length,
|
||||||
|
onPageChanged: _onPageChanged,
|
||||||
|
itemBuilder: (context, page) => _ViewerPhotoCell(
|
||||||
|
photo: items[page],
|
||||||
|
album: widget.album,
|
||||||
|
controller: widget.controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: _infoBar(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部信息栏:上传者 / 时区时间 / 尺寸大小 + 操作行 + photoId 诊断区。
|
||||||
|
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 dims = (photo.width != null && photo.height != null)
|
||||||
|
? '${photo.width}×${photo.height}'
|
||||||
|
: null;
|
||||||
|
final size = _formatBytes(photo.bytes);
|
||||||
|
final meta = <String>[
|
||||||
|
formatAlbumDateTime(
|
||||||
|
photo.uploadedAt,
|
||||||
|
widget.album.timezone,
|
||||||
|
l10n.localeName,
|
||||||
|
),
|
||||||
|
?dims,
|
||||||
|
?size,
|
||||||
|
].join(' · ');
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.72),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: kSpace16,
|
||||||
|
vertical: kSpace8,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
photo.uploader.displayName.isEmpty
|
||||||
|
? photo.uploader.account
|
||||||
|
: photo.uploader.displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
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),
|
||||||
|
// 诊断区域:照片 ID(不与主信息争夺层级)。
|
||||||
|
Text(
|
||||||
|
photo.photoId,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: Colors.white38),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: kSpace16,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_canDelete(photo))
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _delete(photo),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: Text(l10n.delete),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _formatBytes(int bytes) {
|
||||||
|
if (bytes <= 0) return null;
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} KB';
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查看器壳层:关闭按钮(安全区避让)+ 内容。
|
||||||
|
class _ViewerShell extends StatelessWidget {
|
||||||
|
const _ViewerShell({required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单张大图:contain 展示;签名 URL 失败 → 一次受控刷新;无预览(处理
|
||||||
|
/// 中/已删除/无权)显示状态说明。
|
||||||
|
class _ViewerPhotoCell extends StatelessWidget {
|
||||||
|
const _ViewerPhotoCell({
|
||||||
|
required this.photo,
|
||||||
|
required this.album,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AdminPhoto photo;
|
||||||
|
final Album album;
|
||||||
|
final PhotoGridController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final url = photo.preview?.browse.url;
|
||||||
|
if (url == null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.image_not_supported_outlined, size: 48, color: Colors.white38),
|
||||||
|
const SizedBox(height: kSpace12),
|
||||||
|
StateBadge(
|
||||||
|
variant: _stateBadgeVariant(photo),
|
||||||
|
label: _stateBadgeLabel(l10n, photo),
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace8),
|
||||||
|
Text(
|
||||||
|
l10n.photoPreviewUnavailable,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _ViewerImage(
|
||||||
|
imageUrl: url,
|
||||||
|
photoId: photo.photoId,
|
||||||
|
controller: controller,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static BadgeVariant _stateBadgeVariant(AdminPhoto photo) {
|
||||||
|
if (photo.visibility == PhotoVisibility.deleted ||
|
||||||
|
photo.processingState == PhotoProcessingState.processingFailed) {
|
||||||
|
return BadgeVariant.error;
|
||||||
|
}
|
||||||
|
return BadgeVariant.warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _stateBadgeLabel(AppLocalizations l10n, AdminPhoto photo) {
|
||||||
|
if (photo.visibility == PhotoVisibility.deleted) {
|
||||||
|
return l10n.visibilityDeleted;
|
||||||
|
}
|
||||||
|
if (photo.processingState == PhotoProcessingState.processingFailed) {
|
||||||
|
return l10n.procProcessingFailed;
|
||||||
|
}
|
||||||
|
return l10n.procProcessing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 大图 Image.network:失败时自动按原页参数刷新一次签名 URL;仍失败显示
|
||||||
|
/// 占位(不再重试,见 [PhotoGridController.refreshPreview])。
|
||||||
|
class _ViewerImage extends StatefulWidget {
|
||||||
|
const _ViewerImage({
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.photoId,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String imageUrl;
|
||||||
|
final String photoId;
|
||||||
|
final PhotoGridController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ViewerImage> createState() => _ViewerImageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ViewerImageState extends State<_ViewerImage> {
|
||||||
|
bool _autoRefreshScheduled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Image.network(
|
||||||
|
widget.imageUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
loadingBuilder: (context, child, progress) =>
|
||||||
|
progress == null ? child : const Center(
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
errorBuilder: (_, _, _) {
|
||||||
|
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
|
||||||
|
if (!_autoRefreshScheduled && widget.controller.canRefresh(widget.photoId)) {
|
||||||
|
_autoRefreshScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
widget.controller.refreshPreview(widget.photoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
const SizedBox(height: kSpace8),
|
||||||
|
Text(
|
||||||
|
l10n.photoPreviewUnavailable,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
/// 照片管理占位页(P3 计划 Task 4):基础管理列表(可见性/处理状态
|
/// 照片管理页(UI-01 §4.3 重写):三列缩略图网格(极窄两列、平板按最小
|
||||||
/// 标签 + 游标加载更多);完整筛选与操作由 Task 9 队列 UI 替换。
|
/// 格宽扩列)+ 状态角标(处理中/失败/隐藏/删除)+ 筛选 chips + 游标分页
|
||||||
|
/// + 点开大图查看器。
|
||||||
|
///
|
||||||
|
/// 数据范围(scope):先按 all 请求(owner/admin);协作者 403
|
||||||
|
/// AUTH_FORBIDDEN 时自适应回退 own(服务端过滤,仅自己上传)——isManager
|
||||||
|
/// 由此推导,供查看器操作权限(恢复发布仅管理者)使用。缩略图取条目
|
||||||
|
/// preview.thumb;签名 URL 失效经 [PhotoGridController.refreshPreview]
|
||||||
|
/// 受控重取所在页一次(不无限重试)。
|
||||||
|
///
|
||||||
|
/// Processing/Failed 筛选为已加载页的本地过滤(服务端无对应查询参数),
|
||||||
|
/// 结果不足时自动补页(上限内),不声称全量。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@@ -9,12 +21,397 @@ import '../core/api_client.dart';
|
|||||||
import '../core/app_exception.dart';
|
import '../core/app_exception.dart';
|
||||||
import '../core/error_codes.dart';
|
import '../core/error_codes.dart';
|
||||||
import '../core/error_l10n.dart';
|
import '../core/error_l10n.dart';
|
||||||
import '../features/albums/album_format.dart';
|
import '../core/models/cursor_page.dart';
|
||||||
|
import '../core/session_store.dart';
|
||||||
import '../features/albums/models.dart';
|
import '../features/albums/models.dart';
|
||||||
import '../features/photos/models.dart';
|
import '../features/photos/models.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../ui/components/empty_state.dart';
|
||||||
|
import '../ui/components/error_state.dart';
|
||||||
|
import '../ui/components/photo_thumbnail.dart';
|
||||||
|
import '../ui/components/state_badge.dart';
|
||||||
|
import '../ui/tokens.dart';
|
||||||
|
import 'album_photo_viewer_page.dart';
|
||||||
|
|
||||||
/// 照片管理占位(从详情页携带 [album] 进入,时间按相册时区展示)。
|
/// 网格筛选(All / Published / Hidden 走服务端 visibility;Processing /
|
||||||
|
/// Failed 为已加载条目的本地过滤)。
|
||||||
|
enum PhotoGridFilter {
|
||||||
|
all('all'),
|
||||||
|
published('published'),
|
||||||
|
hidden('hidden'),
|
||||||
|
processing('processing'),
|
||||||
|
failed('failed');
|
||||||
|
|
||||||
|
const PhotoGridFilter(this.wire);
|
||||||
|
|
||||||
|
final String wire;
|
||||||
|
|
||||||
|
/// 本地过滤(服务端无 processingState 查询参数)。
|
||||||
|
bool get isLocal => this == PhotoGridFilter.processing ||
|
||||||
|
this == PhotoGridFilter.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一页数据的请求规格(刷新签名 URL 时按原参数重取该页)。
|
||||||
|
@immutable
|
||||||
|
class _PageSpec {
|
||||||
|
const _PageSpec({
|
||||||
|
required this.cursor,
|
||||||
|
required this.visibility,
|
||||||
|
required this.scope,
|
||||||
|
required this.startIndex,
|
||||||
|
required this.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? cursor;
|
||||||
|
final PhotoVisibility? visibility;
|
||||||
|
final AlbumPhotoScope scope;
|
||||||
|
final int startIndex;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照片网格状态机(页面与查看器共享;ChangeNotifier 驱动两处重建)。
|
||||||
|
///
|
||||||
|
/// 职责:scope 自适应(all 403 → own)、游标分页、筛选切换、单张可见性
|
||||||
|
/// 变更后的条目合并、签名 URL 一次性刷新。所有变更经 [notifyListeners]
|
||||||
|
/// 通知;dispose 后静默丢弃(页面退出后迟到的响应不触发断言)。
|
||||||
|
class PhotoGridController extends ChangeNotifier {
|
||||||
|
PhotoGridController({
|
||||||
|
required ApiClient api,
|
||||||
|
required Future<Session?> Function() readSession,
|
||||||
|
required this.albumId,
|
||||||
|
this.pageSize = 20,
|
||||||
|
}) // 私有字段不能作 initializing formal(外部调用方无法传私有名),
|
||||||
|
// 参数名保持公开语义,忽略该风格提示。
|
||||||
|
// ignore: prefer_initializing_formals
|
||||||
|
: _api = api,
|
||||||
|
// ignore: prefer_initializing_formals
|
||||||
|
_readSession = readSession;
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final Future<Session?> Function() _readSession;
|
||||||
|
final String albumId;
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
List<AdminPhoto> _items = const <AdminPhoto>[];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
bool _loading = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
bool _loadMoreFailed = false;
|
||||||
|
String? _errorCode;
|
||||||
|
AlbumPhotoScope _scope = AlbumPhotoScope.all;
|
||||||
|
bool _scopeResolved = false;
|
||||||
|
PhotoGridFilter _filter = PhotoGridFilter.all;
|
||||||
|
final List<_PageSpec> _pages = <_PageSpec>[];
|
||||||
|
final Set<String> _refreshed = <String>{};
|
||||||
|
String? _selfAccount;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// 已加载条目(全量,含本地筛选被排除的)。
|
||||||
|
List<AdminPhoto> get items => _items;
|
||||||
|
|
||||||
|
/// 当前筛选下应显示的条目。
|
||||||
|
List<AdminPhoto> get visibleItems => switch (_filter) {
|
||||||
|
PhotoGridFilter.processing => _items
|
||||||
|
.where((p) => p.processingState != PhotoProcessingState.ready)
|
||||||
|
.toList(growable: false),
|
||||||
|
PhotoGridFilter.failed => _items
|
||||||
|
.where((p) => p.processingState == PhotoProcessingState.processingFailed)
|
||||||
|
.toList(growable: false),
|
||||||
|
_ => _items,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
bool get loading => _loading;
|
||||||
|
bool get loadingMore => _loadingMore;
|
||||||
|
bool get loadMoreFailed => _loadMoreFailed;
|
||||||
|
String? get errorCode => _errorCode;
|
||||||
|
PhotoGridFilter get filter => _filter;
|
||||||
|
AlbumPhotoScope get scope => _scope;
|
||||||
|
|
||||||
|
/// scope=all 且已完成自适应判定 → 当前用户是 owner/admin(恢复发布等
|
||||||
|
/// 管理操作可用;判定依据为服务端 all 列表的 403 语义)。
|
||||||
|
bool get isManager => _scopeResolved && _scope == AlbumPhotoScope.all;
|
||||||
|
|
||||||
|
/// 当前登录账号(「自己上传」判定;读取失败为 null,按非本人处理)。
|
||||||
|
String? get selfAccount => _selfAccount;
|
||||||
|
|
||||||
|
/// 该照片是否仍可发起一次签名 URL 刷新(每张仅一次,不无限重试)。
|
||||||
|
bool canRefresh(String photoId) => !_refreshed.contains(photoId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取当前登录账号(viewer 权限判定用;失败为 null,按非本人处理)。
|
||||||
|
Future<void> loadSelfAccount() async {
|
||||||
|
try {
|
||||||
|
_selfAccount = (await _readSession())?.account;
|
||||||
|
} on Object {
|
||||||
|
_selfAccount = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载(scope 自适应:all 403 → own 重试一次);已判定后等同
|
||||||
|
/// [reload](按已判定 scope 重取第一页)。
|
||||||
|
Future<void> resolveAndLoad() async {
|
||||||
|
if (_scopeResolved) return _reloadFirstPage();
|
||||||
|
_loading = true;
|
||||||
|
_errorCode = null;
|
||||||
|
_loadMoreFailed = false;
|
||||||
|
_notify();
|
||||||
|
final failure = await _tryFirstPage(AlbumPhotoScope.all);
|
||||||
|
if (failure == null) {
|
||||||
|
_scopeResolved = true;
|
||||||
|
_loading = false;
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (failure.code != ErrorCodes.authForbidden) {
|
||||||
|
_errorCode = failure.code;
|
||||||
|
_loading = false;
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 协作者:all → 403,回退 own(服务端过滤仅自己上传)。
|
||||||
|
_scope = AlbumPhotoScope.own;
|
||||||
|
final ownFailure = await _tryFirstPage(AlbumPhotoScope.own);
|
||||||
|
_scopeResolved = true;
|
||||||
|
_errorCode = ownFailure?.code;
|
||||||
|
_loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按已判定 scope 重置到第一页(下拉刷新/筛选切换)。
|
||||||
|
Future<void> _reloadFirstPage() async {
|
||||||
|
_loading = true;
|
||||||
|
_errorCode = null;
|
||||||
|
_loadMoreFailed = false;
|
||||||
|
_notify();
|
||||||
|
final failure = await _tryFirstPage(_scope);
|
||||||
|
_errorCode = failure?.code;
|
||||||
|
_loading = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取第一页;成功返回 null,失败返回异常。
|
||||||
|
Future<AppException?> _tryFirstPage(AlbumPhotoScope scope) async {
|
||||||
|
try {
|
||||||
|
final page = await _fetch(
|
||||||
|
scope: scope,
|
||||||
|
visibility: _serverVisibilityFor(_filter),
|
||||||
|
cursor: null,
|
||||||
|
limit: pageSize,
|
||||||
|
);
|
||||||
|
_items = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_loadMoreFailed = false;
|
||||||
|
_refreshed.clear();
|
||||||
|
_pages
|
||||||
|
..clear()
|
||||||
|
..add(
|
||||||
|
_PageSpec(
|
||||||
|
cursor: null,
|
||||||
|
visibility: _serverVisibilityFor(_filter),
|
||||||
|
scope: scope,
|
||||||
|
startIndex: 0,
|
||||||
|
count: page.items.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} on AppException catch (e) {
|
||||||
|
return e;
|
||||||
|
} on Object {
|
||||||
|
return const AppException(
|
||||||
|
code: ErrorCodes.internalError,
|
||||||
|
message: '照片列表加载失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置到第一页(下拉刷新/筛选切换;scope 判定结果保持不变)。
|
||||||
|
Future<void> reload() => _reloadFirstPage();
|
||||||
|
|
||||||
|
/// 游标加载下一页(加载中/无更多时为空操作;失败保留已加载条目)。
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_loading || _loadingMore || !_hasMore) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
_loadMoreFailed = false;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final cursor = _nextCursor;
|
||||||
|
final page = await _fetch(
|
||||||
|
scope: _scope,
|
||||||
|
visibility: _serverVisibilityFor(_filter),
|
||||||
|
cursor: cursor,
|
||||||
|
limit: pageSize,
|
||||||
|
);
|
||||||
|
_pages.add(
|
||||||
|
_PageSpec(
|
||||||
|
cursor: cursor,
|
||||||
|
visibility: _serverVisibilityFor(_filter),
|
||||||
|
scope: _scope,
|
||||||
|
startIndex: _items.length,
|
||||||
|
count: page.items.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_items = <AdminPhoto>[..._items, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_loadingMore = false;
|
||||||
|
_notify();
|
||||||
|
} on AppException {
|
||||||
|
_loadingMore = false;
|
||||||
|
_loadMoreFailed = true;
|
||||||
|
_notify();
|
||||||
|
} on Object {
|
||||||
|
_loadingMore = false;
|
||||||
|
_loadMoreFailed = true;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换筛选:服务端筛选(all/published/hidden)重载第一页;本地筛选
|
||||||
|
/// (processing/failed)仅重算可见集。
|
||||||
|
Future<void> setFilter(PhotoGridFilter filter) async {
|
||||||
|
if (filter == _filter) return;
|
||||||
|
_filter = filter;
|
||||||
|
if (!filter.isLocal) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 签名 URL 失效受控刷新:按原请求规格重取该照片所在页,按 photoId
|
||||||
|
/// 合并新 preview;每张照片仅一次(成功与否均不再重试)。
|
||||||
|
Future<bool> refreshPreview(String photoId) async {
|
||||||
|
if (_refreshed.contains(photoId)) return false;
|
||||||
|
final index = _items.indexWhere((p) => p.photoId == photoId);
|
||||||
|
if (index < 0) return false;
|
||||||
|
final spec = _pageForIndex(index);
|
||||||
|
if (spec == null) return false;
|
||||||
|
_refreshed.add(photoId);
|
||||||
|
try {
|
||||||
|
final page = await _fetch(
|
||||||
|
scope: spec.scope,
|
||||||
|
visibility: spec.visibility,
|
||||||
|
cursor: spec.cursor,
|
||||||
|
limit: pageSize,
|
||||||
|
);
|
||||||
|
var updated = false;
|
||||||
|
final byId = <String, AdminPhoto>{
|
||||||
|
for (final item in page.items) item.photoId: item,
|
||||||
|
};
|
||||||
|
_items = <AdminPhoto>[
|
||||||
|
for (final item in _items) byId[item.photoId] ?? item,
|
||||||
|
];
|
||||||
|
updated = byId.containsKey(photoId);
|
||||||
|
_notify();
|
||||||
|
return updated;
|
||||||
|
} on AppException {
|
||||||
|
return false;
|
||||||
|
} on Object {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单张可见性变更后合并回列表(保留原 uploader/preview;隐藏不回收
|
||||||
|
/// 已签发的预览——管理者仍可看)。
|
||||||
|
void applyRecord(PhotoRecord record) {
|
||||||
|
_items = <AdminPhoto>[
|
||||||
|
for (final item in _items)
|
||||||
|
if (item.photoId == record.photoId) _withRecord(item, record) else item,
|
||||||
|
];
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除成功后移除条目(页边界不再重算:后续 refreshPreview 按近似页
|
||||||
|
/// 重取,photoId 未命中时按失败处理,安全降级)。
|
||||||
|
void removeById(String photoId) {
|
||||||
|
_items = _items.where((p) => p.photoId != photoId).toList(growable: false);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏(published → hidden;协作者仅限自己上传,服务端裁决)。
|
||||||
|
Future<void> hidePhoto(AdminPhoto photo) async {
|
||||||
|
final record = await _api.updatePhotoVisibility(
|
||||||
|
photo.photoId,
|
||||||
|
PhotoVisibility.hidden,
|
||||||
|
);
|
||||||
|
applyRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复显示(hidden → published;仅 owner/admin,服务端裁决)。
|
||||||
|
Future<void> unhidePhoto(AdminPhoto photo) async {
|
||||||
|
final record = await _api.updatePhotoVisibility(
|
||||||
|
photo.photoId,
|
||||||
|
PhotoVisibility.published,
|
||||||
|
);
|
||||||
|
applyRecord(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除(软删除,云端照片将被移除;权限同隐藏)。
|
||||||
|
Future<void> deletePhoto(AdminPhoto photo) =>
|
||||||
|
_api.deletePhoto(photo.photoId);
|
||||||
|
|
||||||
|
AdminPhoto _withRecord(AdminPhoto src, PhotoRecord rec) => AdminPhoto(
|
||||||
|
uploader: src.uploader,
|
||||||
|
preview: src.preview,
|
||||||
|
photoId: rec.photoId,
|
||||||
|
albumId: rec.albumId,
|
||||||
|
categoryId: rec.categoryId,
|
||||||
|
uploaderId: rec.uploaderId,
|
||||||
|
visibility: rec.visibility,
|
||||||
|
processingState: rec.processingState,
|
||||||
|
bytes: rec.bytes,
|
||||||
|
width: rec.width,
|
||||||
|
height: rec.height,
|
||||||
|
shotAt: rec.shotAt,
|
||||||
|
uploadedAt: rec.uploadedAt,
|
||||||
|
publishedAt: rec.publishedAt,
|
||||||
|
likeCount: rec.likeCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 覆盖 [index] 的页规格(删除导致边界漂移时按近似页处理)。
|
||||||
|
_PageSpec? _pageForIndex(int index) {
|
||||||
|
for (final spec in _pages) {
|
||||||
|
if (index >= spec.startIndex && index < spec.startIndex + spec.count) {
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _pages.isEmpty ? null : _pages.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
static PhotoVisibility? _serverVisibilityFor(PhotoGridFilter filter) =>
|
||||||
|
switch (filter) {
|
||||||
|
PhotoGridFilter.published => PhotoVisibility.published,
|
||||||
|
PhotoGridFilter.hidden => PhotoVisibility.hidden,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<CursorPage<AdminPhoto>> _fetch({
|
||||||
|
required AlbumPhotoScope scope,
|
||||||
|
String? cursor,
|
||||||
|
PhotoVisibility? visibility,
|
||||||
|
int? limit,
|
||||||
|
}) => _api.listAlbumPhotos(
|
||||||
|
albumId,
|
||||||
|
scope: scope,
|
||||||
|
visibility: visibility,
|
||||||
|
cursor: cursor,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照片管理网格页(从详情页携带 [album] 进入,时间按相册时区展示)。
|
||||||
class AlbumPhotosPage extends ConsumerStatefulWidget {
|
class AlbumPhotosPage extends ConsumerStatefulWidget {
|
||||||
const AlbumPhotosPage({required this.album, super.key});
|
const AlbumPhotosPage({required this.album, super.key});
|
||||||
|
|
||||||
@@ -25,298 +422,265 @@ class AlbumPhotosPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
|
class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
|
||||||
static const int _pageSize = 20;
|
PhotoGridController? _controller;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
List<AdminPhoto> _items = const <AdminPhoto>[];
|
|
||||||
String? _nextCursor;
|
|
||||||
bool _hasMore = false;
|
|
||||||
bool _loading = true;
|
|
||||||
bool _loadingMore = false;
|
|
||||||
String? _errorCode;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_reload();
|
final controller = PhotoGridController(
|
||||||
|
api: ref.read(apiClientProvider),
|
||||||
|
readSession: ref.read(sessionStoreProvider).read,
|
||||||
|
albumId: widget.album.id,
|
||||||
|
);
|
||||||
|
_controller = controller;
|
||||||
|
controller
|
||||||
|
..loadSelfAccount()
|
||||||
|
..resolveAndLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reload() async {
|
@override
|
||||||
setState(() {
|
void dispose() {
|
||||||
_loading = true;
|
_controller?.dispose();
|
||||||
_errorCode = null;
|
_scrollController.dispose();
|
||||||
});
|
super.dispose();
|
||||||
try {
|
|
||||||
final page = await ref.read(apiClientProvider).listAlbumPhotos(
|
|
||||||
widget.album.id,
|
|
||||||
limit: _pageSize,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_items = page.items;
|
|
||||||
_nextCursor = page.nextCursor;
|
|
||||||
_hasMore = page.hasMore;
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} on AppException catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_errorCode = e.code;
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} on Object {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_errorCode = ErrorCodes.internalError;
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMore() async {
|
Future<void> _openViewer(int loadedIndex) async {
|
||||||
if (_loading || _loadingMore || !_hasMore) return;
|
final controller = _controller;
|
||||||
setState(() => _loadingMore = true);
|
if (controller == null) return;
|
||||||
try {
|
await Navigator.of(context).push(
|
||||||
final page = await ref.read(apiClientProvider).listAlbumPhotos(
|
MaterialPageRoute<void>(
|
||||||
widget.album.id,
|
fullscreenDialog: true,
|
||||||
cursor: _nextCursor,
|
builder: (_) => AlbumPhotoViewerPage(
|
||||||
limit: _pageSize,
|
controller: controller,
|
||||||
|
album: widget.album,
|
||||||
|
initialIndex: loadedIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (mounted) {
|
}
|
||||||
setState(() {
|
|
||||||
_items = <AdminPhoto>[..._items, ...page.items];
|
@override
|
||||||
_nextCursor = page.nextCursor;
|
Widget build(BuildContext context) {
|
||||||
_hasMore = page.hasMore;
|
final controller = _controller;
|
||||||
_loadingMore = false;
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网格主体:筛选 chips +(协作者 own 范围提示)+ 网格/空态/错误态。
|
||||||
|
class _AlbumPhotosBody extends StatelessWidget {
|
||||||
|
const _AlbumPhotosBody({
|
||||||
|
required this.controller,
|
||||||
|
required this.album,
|
||||||
|
required this.scrollController,
|
||||||
|
required this.onOpenViewer,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} on AppException {
|
final PhotoGridController controller;
|
||||||
if (mounted) {
|
final Album album;
|
||||||
setState(() => _loadingMore = false);
|
final ScrollController scrollController;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final void Function(int loadedIndex) onOpenViewer;
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
/// 本地筛选自动补页上限(防 0 匹配时无限翻页;超过后显示空筛选态)。
|
||||||
AppLocalizations.of(context).messageForErrorCode(
|
static const int _localFilterAutoLoadCap = 100;
|
||||||
ErrorCodes.networkError,
|
|
||||||
),
|
/// 网格列数(方案 §4.3):极窄(<360)两列;默认三列;平板按
|
||||||
),
|
/// width/150 向下取整扩列(不小于 3)。
|
||||||
),
|
static int columnCount(double width) {
|
||||||
);
|
if (width < 360) return 2;
|
||||||
}
|
return math.max(3, width ~/ 150);
|
||||||
} on Object {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _loadingMore = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context).errorGeneric),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
if (controller.loading) {
|
||||||
return Scaffold(
|
return const Center(child: CircularProgressIndicator());
|
||||||
appBar: AppBar(title: Text(l10n.albumPhotos)),
|
}
|
||||||
body: Column(
|
if (controller.items.isEmpty && controller.errorCode != null) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
return ErrorState(
|
||||||
children: [
|
message: l10n.messageForErrorCode(controller.errorCode!),
|
||||||
// Task 9 占位横幅:完整筛选/操作届时替换本页。
|
onRetry: controller.reload,
|
||||||
Card(
|
|
||||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.construction_outlined,
|
|
||||||
size: 18,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l10n.photosTask9Placeholder,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _loading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _items.isEmpty && _errorCode != null
|
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.cloud_off, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(l10n.messageForErrorCode(_errorCode!)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _reload,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: _items.length +
|
|
||||||
(_items.isEmpty || _hasMore || _loadingMore ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (_items.isEmpty) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Text(
|
|
||||||
l10n.albumPhotosEmpty,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (index >= _items.length) {
|
final visible = controller.visibleItems;
|
||||||
return const Padding(
|
return Column(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Center(
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final filter in PhotoGridFilter.values)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: kSpace8),
|
||||||
|
child: FilterChip(
|
||||||
|
label: Text(_filterLabel(l10n, filter)),
|
||||||
|
selected: controller.filter == filter,
|
||||||
|
onSelected: (_) => controller.setFilter(filter),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!controller.isManager)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
kSpace16,
|
||||||
|
kSpace8,
|
||||||
|
kSpace16,
|
||||||
|
kSpace4,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l10n.photosScopeOwnHint,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: _gridOrEmpty(context, l10n, visible)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _gridOrEmpty(
|
||||||
|
BuildContext context,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
List<AdminPhoto> visible,
|
||||||
|
) {
|
||||||
|
if (visible.isEmpty) {
|
||||||
|
final filtered = controller.filter != PhotoGridFilter.all;
|
||||||
|
return EmptyState(
|
||||||
|
icon: Icons.photo_outlined,
|
||||||
|
title: filtered ? l10n.photosEmptyFiltered : l10n.albumPhotosEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final columns = columnCount(constraints.maxWidth);
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: controller.reload,
|
||||||
|
child: GridView.builder(
|
||||||
|
controller: scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(kSpace4),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: kSpace4,
|
||||||
|
crossAxisSpacing: kSpace4,
|
||||||
|
),
|
||||||
|
itemCount: visible.length + (controller.hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= visible.length) {
|
||||||
|
return const Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 24,
|
height: 24,
|
||||||
width: 24,
|
width: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (index >= _items.length - 3 &&
|
_maybeLoadMore(index, visible.length);
|
||||||
_hasMore &&
|
final photo = visible[index];
|
||||||
!_loadingMore) {
|
final loadedIndex = controller.items.indexOf(photo);
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
return _PhotoGridTile(
|
||||||
(_) => _loadMore(),
|
photo: photo,
|
||||||
|
onTap: () => onOpenViewer(loadedIndex),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return _photoTile(l10n, theme, _items[index]);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _photoTile(
|
/// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。
|
||||||
AppLocalizations l10n,
|
void _maybeLoadMore(int index, int visibleCount) {
|
||||||
ThemeData theme,
|
final nearEnd = index >= visibleCount - 6;
|
||||||
AdminPhoto photo,
|
final localStarved = controller.filter.isLocal &&
|
||||||
) {
|
visibleCount < 9 &&
|
||||||
return ListTile(
|
controller.items.length < _localFilterAutoLoadCap;
|
||||||
leading: Container(
|
if ((nearEnd || localStarved) &&
|
||||||
width: 44,
|
controller.hasMore &&
|
||||||
height: 44,
|
!controller.loadingMore &&
|
||||||
decoration: BoxDecoration(
|
!controller.loading) {
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore());
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.image_outlined),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'${photo.photoId.substring(0, 8)} · ${(photo.bytes / 1024).round()} KB',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
formatAlbumDateTime(
|
|
||||||
photo.uploadedAt,
|
|
||||||
widget.album.timezone,
|
|
||||||
l10n.localeName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Wrap(
|
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 4,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
_PhotoTag(
|
|
||||||
label: _visibilityLabel(l10n, photo.visibility),
|
|
||||||
highlight: photo.visibility == PhotoVisibility.published,
|
|
||||||
),
|
|
||||||
_PhotoTag(
|
|
||||||
label: _processingLabel(l10n, photo.processingState),
|
|
||||||
highlight: photo.processingState == PhotoProcessingState.ready,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _visibilityLabel(AppLocalizations l10n, PhotoVisibility v) {
|
|
||||||
switch (v) {
|
|
||||||
case PhotoVisibility.published:
|
|
||||||
return l10n.visibilityPublished;
|
|
||||||
case PhotoVisibility.unpublished:
|
|
||||||
return l10n.visibilityUnpublished;
|
|
||||||
case PhotoVisibility.hidden:
|
|
||||||
return l10n.visibilityHidden;
|
|
||||||
case PhotoVisibility.deleted:
|
|
||||||
return l10n.visibilityDeleted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _processingLabel(
|
static String _filterLabel(AppLocalizations l10n, PhotoGridFilter filter) =>
|
||||||
AppLocalizations l10n,
|
switch (filter) {
|
||||||
PhotoProcessingState s,
|
PhotoGridFilter.all => l10n.photoFilterAll,
|
||||||
) {
|
PhotoGridFilter.published => l10n.visibilityPublished,
|
||||||
switch (s) {
|
PhotoGridFilter.hidden => l10n.visibilityHidden,
|
||||||
case PhotoProcessingState.pendingUpload:
|
PhotoGridFilter.processing => l10n.procProcessing,
|
||||||
return l10n.procPendingUpload;
|
PhotoGridFilter.failed => l10n.photoFilterFailed,
|
||||||
case PhotoProcessingState.uploaded:
|
};
|
||||||
return l10n.procUploaded;
|
|
||||||
case PhotoProcessingState.processing:
|
|
||||||
return l10n.procProcessing;
|
|
||||||
case PhotoProcessingState.ready:
|
|
||||||
return l10n.procReady;
|
|
||||||
case PhotoProcessingState.processingFailed:
|
|
||||||
return l10n.procProcessingFailed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 照片标签(可见性/处理状态)。
|
/// 网格瓦片:1:1 缩略图(cover 裁切)+ 左上角状态角标。
|
||||||
class _PhotoTag extends StatelessWidget {
|
class _PhotoGridTile extends StatelessWidget {
|
||||||
const _PhotoTag({required this.label, required this.highlight});
|
const _PhotoGridTile({required this.photo, required this.onTap});
|
||||||
|
|
||||||
final String label;
|
final AdminPhoto photo;
|
||||||
final bool highlight;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// 角标裁决(优先级:删除 > 失败 > 隐藏 > 处理中;就绪且公开无角标)。
|
||||||
|
static (String, BadgeVariant)? _badge(AppLocalizations l10n, AdminPhoto p) {
|
||||||
|
if (p.visibility == PhotoVisibility.deleted) {
|
||||||
|
return (l10n.visibilityDeleted, BadgeVariant.neutral);
|
||||||
|
}
|
||||||
|
if (p.processingState == PhotoProcessingState.processingFailed) {
|
||||||
|
return (l10n.procProcessingFailed, BadgeVariant.error);
|
||||||
|
}
|
||||||
|
if (p.visibility == PhotoVisibility.hidden) {
|
||||||
|
return (l10n.visibilityHidden, BadgeVariant.neutral);
|
||||||
|
}
|
||||||
|
if (p.processingState != PhotoProcessingState.ready) {
|
||||||
|
return (l10n.procProcessing, BadgeVariant.warning);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return Container(
|
final badge = _badge(l10n, photo);
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
return InkWell(
|
||||||
decoration: BoxDecoration(
|
onTap: onTap,
|
||||||
color: highlight
|
child: Stack(
|
||||||
? theme.colorScheme.secondaryContainer
|
fit: StackFit.expand,
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
children: [
|
||||||
borderRadius: BorderRadius.circular(999),
|
PhotoThumbnail(
|
||||||
|
url: photo.preview?.thumb.url,
|
||||||
|
aspectRatio: 1,
|
||||||
),
|
),
|
||||||
child: Text(
|
if (badge != null)
|
||||||
label,
|
Positioned(
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
top: kSpace4,
|
||||||
color: highlight
|
left: kSpace4,
|
||||||
? theme.colorScheme.onSecondaryContainer
|
child: StateBadge(
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
variant: badge.$2,
|
||||||
|
label: badge.$1,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1 样板)。
|
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。
|
||||||
///
|
///
|
||||||
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
|
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
|
||||||
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。完整列表
|
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列;
|
||||||
/// 交互(平板两列、错误重试等)随后续任务迭代。
|
/// 可用宽度 > 600 双列(行内卡片等高,不挤压英文标题)。publicToken 与
|
||||||
|
/// 四开关堆叠不在主卡片(完整配置在详情设置)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -21,11 +22,15 @@ import '../ui/tokens.dart';
|
|||||||
import 'album_detail_page.dart';
|
import 'album_detail_page.dart';
|
||||||
import 'album_form_page.dart';
|
import 'album_form_page.dart';
|
||||||
|
|
||||||
|
/// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。
|
||||||
|
const double kAlbumTwoColumnMinWidth = 600;
|
||||||
|
|
||||||
/// 首页「相册」页签。
|
/// 首页「相册」页签。
|
||||||
class AlbumsTab extends ConsumerWidget {
|
class AlbumsTab extends ConsumerWidget {
|
||||||
const AlbumsTab({super.key});
|
const AlbumsTab({super.key});
|
||||||
|
|
||||||
Future<void> _openCreate(BuildContext context, WidgetRef ref) async {
|
/// 打开创建表单;成功后刷新列表(空态 CTA 与 FAB 共用)。
|
||||||
|
static Future<void> openCreate(BuildContext context, WidgetRef ref) async {
|
||||||
final changed = await Navigator.of(context).push<bool>(
|
final changed = await Navigator.of(context).push<bool>(
|
||||||
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
|
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
|
||||||
);
|
);
|
||||||
@@ -41,7 +46,7 @@ class AlbumsTab extends ConsumerWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(l10n.albums)),
|
appBar: AppBar(title: Text(l10n.albums)),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => _openCreate(context, ref),
|
onPressed: () => openCreate(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(l10n.albumCreate),
|
label: Text(l10n.albumCreate),
|
||||||
),
|
),
|
||||||
@@ -51,6 +56,10 @@ class AlbumsTab extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
|
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
|
||||||
|
///
|
||||||
|
/// 空态带「新建相册」入口;错误态带重试。列表按可用宽度切换单/双列,
|
||||||
|
/// 行结构 [ListView.builder](行 = 1-2 张卡片),保证下拉刷新与游标
|
||||||
|
/// 加载更多行为与旧版一致。
|
||||||
class AlbumListBody extends ConsumerWidget {
|
class AlbumListBody extends ConsumerWidget {
|
||||||
const AlbumListBody({required this.state, super.key});
|
const AlbumListBody({required this.state, super.key});
|
||||||
|
|
||||||
@@ -74,22 +83,32 @@ class AlbumListBody extends ConsumerWidget {
|
|||||||
return EmptyState(
|
return EmptyState(
|
||||||
icon: Icons.photo_library_outlined,
|
icon: Icons.photo_library_outlined,
|
||||||
title: l10n.albumsEmpty,
|
title: l10n.albumsEmpty,
|
||||||
|
actionLabel: l10n.albumCreate,
|
||||||
|
onAction: () => AlbumsTab.openCreate(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final twoColumns = constraints.maxWidth > kAlbumTwoColumnMinWidth;
|
||||||
|
final rowCount = (state.items.length / (twoColumns ? 2 : 1)).ceil();
|
||||||
final hasFooter = state.hasMore || state.loadMoreFailed;
|
final hasFooter = state.hasMore || state.loadMoreFailed;
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: notifier.refresh,
|
onRefresh: notifier.refresh,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
itemCount: state.items.length + (hasFooter ? 1 : 0),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
kSpace16,
|
||||||
|
kSpace8,
|
||||||
|
kSpace16,
|
||||||
|
kSpace32,
|
||||||
|
),
|
||||||
|
itemCount: rowCount + (hasFooter ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index >= state.items.length) {
|
if (index >= rowCount) {
|
||||||
if (state.loadMoreFailed) {
|
if (state.loadMoreFailed) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: EdgeInsets.zero,
|
||||||
horizontal: kSpace16,
|
|
||||||
),
|
|
||||||
leading: const Icon(Icons.error_outline),
|
leading: const Icon(Icons.error_outline),
|
||||||
title: Text(l10n.loadMoreFailed),
|
title: Text(l10n.loadMoreFailed),
|
||||||
onTap: notifier.loadMore,
|
onTap: notifier.loadMore,
|
||||||
@@ -107,17 +126,33 @@ class AlbumListBody extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
|
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
|
||||||
if (index >= state.items.length - 3 &&
|
if (index >= rowCount - 2 && state.hasMore && !state.loadingMore) {
|
||||||
state.hasMore &&
|
|
||||||
!state.loadingMore) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
(_) => notifier.loadMore(),
|
(_) => notifier.loadMore(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!twoColumns) {
|
||||||
return AlbumCard(album: state.items[index]);
|
return AlbumCard(album: state.items[index]);
|
||||||
|
}
|
||||||
|
final first = index * 2;
|
||||||
|
final hasSecond = first + 1 < state.items.length;
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: AlbumCard(album: state.items[first])),
|
||||||
|
if (hasSecond) ...[
|
||||||
|
const SizedBox(width: kSpace12),
|
||||||
|
Expanded(child: AlbumCard(album: state.items[first + 1])),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +175,7 @@ class AlbumCard extends StatelessWidget {
|
|||||||
? _timeRange(l10n)
|
? _timeRange(l10n)
|
||||||
: '${_timeRange(l10n)} · $venue';
|
: '${_timeRange(l10n)} · $venue';
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(vertical: kSpace8),
|
||||||
horizontal: kSpace16,
|
|
||||||
vertical: kSpace8,
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
|
|||||||
@@ -389,11 +389,13 @@ void main() {
|
|||||||
'alb-1',
|
'alb-1',
|
||||||
visibility: PhotoVisibility.hidden,
|
visibility: PhotoVisibility.hidden,
|
||||||
categoryId: 'cat-9',
|
categoryId: 'cat-9',
|
||||||
|
scope: AlbumPhotoScope.own,
|
||||||
);
|
);
|
||||||
|
|
||||||
final query = adapter.requests.single.query;
|
final query = adapter.requests.single.query;
|
||||||
expect(query, containsPair('visibility', 'hidden'));
|
expect(query, containsPair('visibility', 'hidden'));
|
||||||
expect(query, containsPair('categoryId', 'cat-9'));
|
expect(query, containsPair('categoryId', 'cat-9'));
|
||||||
|
expect(query, containsPair('scope', 'own'));
|
||||||
expect(query.containsKey('cursor'), isFalse);
|
expect(query.containsKey('cursor'), isFalse);
|
||||||
expect(query.containsKey('limit'), isFalse);
|
expect(query.containsKey('limit'), isFalse);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -262,6 +262,9 @@ Map<String, dynamic> adminPhotoJson({
|
|||||||
String visibility = 'published',
|
String visibility = 'published',
|
||||||
String processingState = 'ready',
|
String processingState = 'ready',
|
||||||
int bytes = 123456,
|
int bytes = 123456,
|
||||||
|
String uploaderAccount = 'staff02',
|
||||||
|
String uploaderName = 'Staff Two',
|
||||||
|
Map<String, dynamic>? preview,
|
||||||
}) => <String, dynamic>{
|
}) => <String, dynamic>{
|
||||||
'photoId': photoId,
|
'photoId': photoId,
|
||||||
'albumId': albumId,
|
'albumId': albumId,
|
||||||
@@ -278,10 +281,25 @@ Map<String, dynamic> adminPhotoJson({
|
|||||||
'likeCount': 0,
|
'likeCount': 0,
|
||||||
'uploader': <String, dynamic>{
|
'uploader': <String, dynamic>{
|
||||||
'userId': 'u-2',
|
'userId': 'u-2',
|
||||||
'account': 'staff02',
|
'account': uploaderAccount,
|
||||||
'displayName': 'Staff Two',
|
'displayName': uploaderName,
|
||||||
|
},
|
||||||
|
'preview': preview,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 契约 `PhotoPreview` 响应体(thumb/browse 签名 URL)。
|
||||||
|
Map<String, dynamic> photoPreviewJson({
|
||||||
|
String thumbUrl = 'http://test.local/thumb.jpg',
|
||||||
|
String browseUrl = 'http://test.local/browse.jpg',
|
||||||
|
}) => <String, dynamic>{
|
||||||
|
'thumb': {
|
||||||
|
'url': thumbUrl,
|
||||||
|
'expiresAt': '2026-09-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
'browse': {
|
||||||
|
'url': browseUrl,
|
||||||
|
'expiresAt': '2026-09-22T00:00:00Z',
|
||||||
},
|
},
|
||||||
'preview': null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
|
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/// 相册详情页 widget 测试(P3 计划 Task 4)。
|
/// 相册详情页 widget 测试(UI-01 §4.2 重构后)。
|
||||||
///
|
///
|
||||||
/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行;
|
/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 →
|
||||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级
|
||||||
|
/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上
|
||||||
|
/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类
|
||||||
|
/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍
|
||||||
|
/// 页与多选入队。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -12,6 +16,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:photo_live/core/api_client.dart';
|
import 'package:photo_live/core/api_client.dart';
|
||||||
|
import 'package:photo_live/core/error_codes.dart';
|
||||||
import 'package:photo_live/core/notification_permission.dart';
|
import 'package:photo_live/core/notification_permission.dart';
|
||||||
import 'package:photo_live/core/session_store.dart';
|
import 'package:photo_live/core/session_store.dart';
|
||||||
import 'package:photo_live/features/uploads/enqueue.dart';
|
import 'package:photo_live/features/uploads/enqueue.dart';
|
||||||
@@ -40,16 +45,27 @@ void main() {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。
|
/// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认
|
||||||
|
/// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。
|
||||||
FakeAdapter detailAdapter({
|
FakeAdapter detailAdapter({
|
||||||
|
List<Map<String, dynamic>>? photos,
|
||||||
|
bool collaborator = false,
|
||||||
(int, Object?)? Function(String method, String path)? onCategoryMutation,
|
(int, Object?)? Function(String method, String path)? onCategoryMutation,
|
||||||
}) =>
|
}) =>
|
||||||
FakeAdapter((o) {
|
FakeAdapter((o) {
|
||||||
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
|
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
|
||||||
return (200, albumJson(categories: categories));
|
return (200, albumJson(categories: categories));
|
||||||
}
|
}
|
||||||
|
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
|
||||||
|
final scope = o.uri.queryParameters['scope'];
|
||||||
|
if (collaborator && scope == 'all') {
|
||||||
|
return (403, errorBody(ErrorCodes.authForbidden));
|
||||||
|
}
|
||||||
|
return (200, cursorPageBody(photos ?? <Map<String, dynamic>>[]));
|
||||||
|
}
|
||||||
if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') {
|
if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') {
|
||||||
return onCategoryMutation?.call('POST', o.path) ?? (201, categoryJson(id: 'cat-new'));
|
return onCategoryMutation?.call('POST', o.path) ??
|
||||||
|
(201, categoryJson(id: 'cat-new'));
|
||||||
}
|
}
|
||||||
if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') {
|
if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') {
|
||||||
return onCategoryMutation?.call('PATCH', o.path) ??
|
return onCategoryMutation?.call('PATCH', o.path) ??
|
||||||
@@ -70,11 +86,9 @@ void main() {
|
|||||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
// 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道
|
||||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
// 在测试环境挂起,必须注入替身。
|
||||||
overrides: [
|
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||||
...pageTestOverrides(session: store, client: client),
|
|
||||||
],
|
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
@@ -85,31 +99,165 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async {
|
/// 新布局下分类区在折叠线以下:先滚动到位再交互。
|
||||||
final adapter = detailAdapter();
|
Future<void> reveal(WidgetTester tester, Finder finder) async {
|
||||||
|
await tester.ensureVisible(finder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('布局结构:头部 → 状态 → 照片入口 → 次级操作 → 底部上传栏',
|
||||||
|
(tester) async {
|
||||||
|
final adapter = detailAdapter(
|
||||||
|
photos: List<Map<String, dynamic>>.generate(
|
||||||
|
3,
|
||||||
|
(i) => adminPhotoJson(
|
||||||
|
photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}',
|
||||||
|
preview: photoPreviewJson(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await pumpDetail(tester, clientOf(adapter));
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
// 信息区(标题同时出现在 AppBar 与信息卡)。
|
// 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行
|
||||||
expect(find.text('开幕晚宴'), findsNWidgets(2));
|
// 时间/场地(相册时区墙钟 + 场地语言回退)。
|
||||||
expect(find.text('Active'), findsOneWidget);
|
final body = find.byType(SingleChildScrollView);
|
||||||
expect(
|
expect(
|
||||||
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'),
|
find.descendant(of: body, matching: find.text('Opening Gala')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
expect(
|
||||||
expect(find.text('大宴会厅'), findsOneWidget);
|
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai) · 大宴会厅'),
|
||||||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
findsOneWidget,
|
||||||
expect(find.text('Upload'), findsNWidgets(2));
|
);
|
||||||
expect(find.text('Original'), findsOneWidget);
|
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||||
// 动作行(5 项)。
|
// 预览条缩略图(签名 URL 测试环境失败 → 替代图,不破图)。
|
||||||
expect(find.text('Edit'), findsOneWidget);
|
expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3));
|
||||||
expect(find.text('Members'), findsOneWidget);
|
|
||||||
// 「Categories」同时出现在动作按钮与分区标题。
|
// 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。
|
||||||
expect(find.text('Categories'), findsNWidgets(2));
|
expect(find.text('Public'), findsOneWidget);
|
||||||
expect(find.text('Share QR code'), findsOneWidget);
|
expect(find.text('Uploads on'), findsOneWidget);
|
||||||
expect(find.text('Photos'), findsOneWidget);
|
expect(find.text('Download'), findsNothing);
|
||||||
// 分类条目(zh · en 名称连接)。
|
expect(find.text('Original'), findsNothing);
|
||||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
expect(find.text('sharetoken000000000001'), findsNothing);
|
||||||
|
|
||||||
|
// 照片入口:分区标题 + 「管理照片」入口 + 3 张预览缩略图。
|
||||||
|
expect(find.text('Manage photos'), findsOneWidget);
|
||||||
|
|
||||||
|
// 次级操作(ListTile,不再与主操作同级)。
|
||||||
|
expect(find.widgetWithText(ListTile, 'Edit'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(ListTile, 'Members'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(ListTile, 'Categories'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
|
||||||
|
|
||||||
|
// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。
|
||||||
|
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
|
||||||
|
final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||||||
|
expect(capture, findsOneWidget);
|
||||||
|
expect(pick, findsOneWidget);
|
||||||
|
|
||||||
|
// 垂直顺序:标题 → 徽章 → 管理照片 → 底部栏(次级操作在 body 列内,
|
||||||
|
// 其在树中先于 bottomNavigationBar,几何位置随滚动变化不作断言)。
|
||||||
|
final titleTop = tester
|
||||||
|
.getRect(find.descendant(of: body, matching: find.text('Opening Gala')))
|
||||||
|
.top;
|
||||||
|
final badgeTop = tester.getRect(find.text('Public')).top;
|
||||||
|
final manageTop = tester.getRect(find.text('Manage photos')).top;
|
||||||
|
final captureTop = tester.getRect(capture).top;
|
||||||
|
expect(titleTop, lessThan(badgeTop));
|
||||||
|
expect(badgeTop, lessThan(manageTop));
|
||||||
|
expect(manageTop, lessThan(captureTop));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('无照片:空态引导「拍摄第一张照片」(上传开启时可用)',
|
||||||
|
(tester) async {
|
||||||
|
await pumpDetail(tester, clientOf(detailAdapter()));
|
||||||
|
|
||||||
|
expect(find.text('No photos yet'), findsOneWidget);
|
||||||
|
final cta = find.widgetWithText(FilledButton, 'Take the first photo');
|
||||||
|
expect(cta, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.widget<FilledButton>(cta).onPressed,
|
||||||
|
isNotNull,
|
||||||
|
reason: '上传开启时引导可用',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('上传关闭:底部栏说明原因并禁用双入口 + 引导禁用', (tester) async {
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
|
||||||
|
return (200, albumJson(categories: categories, uploadEnabled: false));
|
||||||
|
}
|
||||||
|
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
|
||||||
|
return (200, cursorPageBody(<Map<String, dynamic>>[]));
|
||||||
|
}
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
});
|
||||||
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Uploads are off for this album. New photos cannot be added right now',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, 'Capture and upload'),
|
||||||
|
)
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<OutlinedButton>(
|
||||||
|
find.widgetWithText(OutlinedButton, 'Pick from gallery'),
|
||||||
|
)
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, 'Take the first photo'),
|
||||||
|
)
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
reason: '空态引导与底部栏同一开关',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览',
|
||||||
|
(tester) async {
|
||||||
|
final adapter = detailAdapter(
|
||||||
|
photos: <Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-own-1',
|
||||||
|
uploaderAccount: 'staff02',
|
||||||
|
preview: photoPreviewJson(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
collaborator: true,
|
||||||
|
);
|
||||||
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
// scope 自适应:all(403)→ own(200)。
|
||||||
|
final photoRequests = adapter.requests
|
||||||
|
.where((r) => r.path == '/v1/albums/alb-1/photos')
|
||||||
|
.toList();
|
||||||
|
expect(photoRequests, hasLength(2));
|
||||||
|
expect(photoRequests.first.uri.queryParameters['scope'], 'all');
|
||||||
|
expect(photoRequests.last.uri.queryParameters['scope'], 'own');
|
||||||
|
|
||||||
|
// 管理入口仅创建者/管理员可见;分享对全体成员开放。
|
||||||
|
expect(find.widgetWithText(ListTile, 'Edit'), findsNothing);
|
||||||
|
expect(find.widgetWithText(ListTile, 'Members'), findsNothing);
|
||||||
|
expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏');
|
||||||
|
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
|
||||||
|
|
||||||
|
// 预览条退化为自己的上传(1 张;头部封面占位另 1 张)。
|
||||||
|
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
|
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
|
||||||
@@ -136,7 +284,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await pumpDetail(tester, clientOf(adapter));
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
await reveal(tester, find.byIcon(Icons.add_circle_outline));
|
||||||
|
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
@@ -176,7 +325,8 @@ void main() {
|
|||||||
await pumpDetail(tester, clientOf(adapter));
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
final requestsBefore = adapter.requests.length;
|
final requestsBefore = adapter.requests.length;
|
||||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
await reveal(tester, find.byIcon(Icons.add_circle_outline));
|
||||||
|
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.text('Save'));
|
||||||
@@ -213,7 +363,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await pumpDetail(tester, clientOf(adapter));
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
await tester.tap(find.text('仪式 · Ceremony'));
|
await reveal(tester, find.text('仪式 · Ceremony'));
|
||||||
|
await tester.tap(find.text('仪式 · Ceremony').hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// 预填检查。
|
// 预填检查。
|
||||||
@@ -260,7 +411,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await pumpDetail(tester, clientOf(adapter));
|
await pumpDetail(tester, clientOf(adapter));
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
await reveal(tester, find.byIcon(Icons.delete_outline));
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -272,7 +424,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
await reveal(tester, find.byIcon(Icons.delete_outline));
|
||||||
|
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -287,7 +440,7 @@ void main() {
|
|||||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('上传入口 FAB(Task 9)', () {
|
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
|
||||||
late Directory tmp;
|
late Directory tmp;
|
||||||
late List<String> galleryPaths;
|
late List<String> galleryPaths;
|
||||||
late TaskStore taskStore;
|
late TaskStore taskStore;
|
||||||
@@ -344,47 +497,33 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async {
|
||||||
await pumpDetailForUpload(tester);
|
await pumpDetailForUpload(tester);
|
||||||
|
|
||||||
await tester.tap(find.byType(FloatingActionButton));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Capture and upload'), findsOneWidget);
|
|
||||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
|
||||||
await pumpDetailForUpload(tester);
|
|
||||||
|
|
||||||
await tester.tap(find.byType(FloatingActionButton));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.text('Capture and upload'));
|
await tester.tap(find.text('Capture and upload'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(CapturePage), findsOneWidget);
|
expect(find.byType(CapturePage), findsOneWidget);
|
||||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
// 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byType(CapturePage),
|
of: find.byType(CapturePage),
|
||||||
matching: find.text('开幕晚宴'),
|
matching: find.text('Opening Gala'),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
testWidgets('「从相册选择」→ 多选入队 + 入队数提示', (tester) async {
|
||||||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||||||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||||||
galleryPaths.addAll([pick1.path, pick2.path]);
|
galleryPaths.addAll([pick1.path, pick2.path]);
|
||||||
await pumpDetailForUpload(tester);
|
await pumpDetailForUpload(tester);
|
||||||
|
|
||||||
await tester.tap(find.byType(FloatingActionButton));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||||||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
(tile.evaluate().single.widget as ListTile).onTap!();
|
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
});
|
});
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
/// 照片大图查看器 widget 测试(UI-01 §4.3)。
|
||||||
|
///
|
||||||
|
/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + ID 诊断区)、左右切换、
|
||||||
|
/// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、
|
||||||
|
/// 删除确认(云端照片将被删除)、关闭返回网格原位置、签名 URL 失败一次
|
||||||
|
/// 受控刷新、临近末尾加载更多。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:photo_live/core/api_client.dart';
|
||||||
|
import 'package:photo_live/core/error_codes.dart';
|
||||||
|
import 'package:photo_live/core/session_store.dart';
|
||||||
|
import 'package:photo_live/features/albums/models.dart';
|
||||||
|
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 '../helpers/fakes.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeSessionStore store;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
store = FakeSessionStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||||
|
sessionStore: store,
|
||||||
|
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||||
|
..httpClientAdapter = adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 网格 + 可选打开查看器;返回打开前的网格 Scrollable 像素偏移。
|
||||||
|
Future<double> pumpGridAndOpen(
|
||||||
|
WidgetTester tester,
|
||||||
|
ApiClient client, {
|
||||||
|
int tile = 0,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final gridScrollable = find.descendant(
|
||||||
|
of: find.byType(GridView),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
final before = tester
|
||||||
|
.state<ScrollableState>(gridScrollable)
|
||||||
|
.position
|
||||||
|
.pixels;
|
||||||
|
await tester.tap(find.byType(PhotoThumbnail).at(tile));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return before;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('信息栏:上传者 / 相册时区时间 / 尺寸大小 + ID 诊断区', (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-info-1', preview: photoPreviewJson()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||||||
|
expect(find.text('Staff Two'), findsOneWidget);
|
||||||
|
// uploadedAt 07:00Z → 相册时区上海 15:00;尺寸 + 大小。
|
||||||
|
expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget);
|
||||||
|
// ID 放诊断区域(弱化呈现)。
|
||||||
|
expect(find.text('ph-info-1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('左右切换已加载照片 + 临近末尾加载更多', (tester) async {
|
||||||
|
final page1 = List<Map<String, dynamic>>.generate(
|
||||||
|
20,
|
||||||
|
(i) => adminPhotoJson(
|
||||||
|
photoId: 'ph-v${(i + 1).toString().padLeft(2, '0')}',
|
||||||
|
uploaderName: 'Uploader ${i + 1}',
|
||||||
|
preview: photoPreviewJson(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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: 'vc1'));
|
||||||
|
}
|
||||||
|
if (cursor == 'vc1') {
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(photoId: 'ph-vpage2', preview: photoPreviewJson()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw StateError('unexpected cursor $cursor');
|
||||||
|
});
|
||||||
|
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
// 初始第一张。
|
||||||
|
expect(find.text('Uploader 1'), findsOneWidget);
|
||||||
|
// 左右切换:到第二张。
|
||||||
|
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Uploader 2'), findsOneWidget);
|
||||||
|
|
||||||
|
// 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
adapter.requests.any((r) => r.uri.queryParameters['cursor'] == 'vc1'),
|
||||||
|
isTrue,
|
||||||
|
reason: '临近末尾按游标加载第二页',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('管理者:已发布照片可隐藏/删除;隐藏照片可恢复', (tester) async {
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path == '/v1/albums/alb-1/photos') {
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(photoId: 'ph-pub', preview: photoPreviewJson()),
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-hid',
|
||||||
|
visibility: 'hidden',
|
||||||
|
preview: photoPreviewJson(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (o.method == 'PATCH' && o.path == '/v1/photos/ph-pub') {
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
adminPhotoJson(photoId: 'ph-pub', visibility: 'hidden'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
});
|
||||||
|
final client = clientOf(adapter);
|
||||||
|
await pumpGridAndOpen(tester, client);
|
||||||
|
|
||||||
|
// 已发布:隐藏 + 删除(管理者,scope=all 成功)。
|
||||||
|
expect(find.text('Hide'), findsOneWidget);
|
||||||
|
expect(find.text('Delete'), findsOneWidget);
|
||||||
|
expect(find.text('Unhide'), findsNothing);
|
||||||
|
|
||||||
|
// 切到第二张(隐藏照片):恢复显示(仅管理者)+ 删除。
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
expect(patch.body, <String, dynamic>{'visibility': 'hidden'});
|
||||||
|
expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复');
|
||||||
|
expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('协作者(scope=own):自己上传可隐藏/删除,不可恢复', (tester) async {
|
||||||
|
store.session = Session(
|
||||||
|
token: 'tok-1',
|
||||||
|
expiresAt: DateTime.utc(2099, 1, 1),
|
||||||
|
account: 'staff02',
|
||||||
|
);
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path != '/v1/albums/alb-1/photos') {
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
}
|
||||||
|
final scope = o.uri.queryParameters['scope'];
|
||||||
|
if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden));
|
||||||
|
if (scope == 'own') {
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-own-hidden',
|
||||||
|
visibility: 'hidden',
|
||||||
|
uploaderAccount: 'staff02',
|
||||||
|
preview: null,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw StateError('unexpected scope $scope');
|
||||||
|
});
|
||||||
|
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
// 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。
|
||||||
|
expect(find.text('Unhide'), findsNothing);
|
||||||
|
expect(find.text('Delete'), findsOneWidget, reason: '自己上传可删除');
|
||||||
|
// 无预览(隐藏对协作者不签发)→ 状态说明 + 图片暂不可用。
|
||||||
|
expect(find.text('Image unavailable'), findsOneWidget);
|
||||||
|
expect(find.text('ph-own-hidden'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('删除:确认「云端照片」→ DELETE → 条目移除', (tester) async {
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path == '/v1/albums/alb-1/photos') {
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(photoId: 'ph-del', preview: photoPreviewJson()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (o.method == 'DELETE' && o.path == '/v1/photos/ph-del') {
|
||||||
|
return (204, null);
|
||||||
|
}
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
});
|
||||||
|
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
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.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// 取消不删。
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
adapter.requests.any((r) => r.method == 'DELETE'),
|
||||||
|
isFalse,
|
||||||
|
reason: '取消不触发删除',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
adapter.requests.any(
|
||||||
|
(r) => r.method == 'DELETE' && r.path == '/v1/photos/ph-del',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
// 唯一照片删除后 → 查看器退回网格(空态)。
|
||||||
|
expect(find.byType(AlbumPhotoViewerPage), findsNothing);
|
||||||
|
expect(find.text('No photos yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('关闭返回网格原滚动位置', (tester) async {
|
||||||
|
// 默认测试画布 800 宽 → 5 列;24 条 ≈ 5 行,超出视口可滚动。
|
||||||
|
final items = List<Map<String, dynamic>>.generate(
|
||||||
|
24,
|
||||||
|
(i) => adminPhotoJson(
|
||||||
|
photoId: 'ph-s${(i + 1).toString().padLeft(2, '0')}',
|
||||||
|
preview: photoPreviewJson(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path != '/v1/albums/alb-1/photos') {
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
}
|
||||||
|
return (200, cursorPageBody(items));
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [...pageTestOverrides(session: store, client: clientOf(adapter))],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 先滚动网格(离开顶部)。
|
||||||
|
final gridScrollable = find.descendant(
|
||||||
|
of: find.byType(GridView),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
await tester.drag(gridScrollable, const Offset(0, -600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final scrolled = tester
|
||||||
|
.state<ScrollableState>(gridScrollable)
|
||||||
|
.position
|
||||||
|
.pixels;
|
||||||
|
expect(scrolled, greaterThan(0));
|
||||||
|
|
||||||
|
// 打开当前视口内可见的一张(滚动后树序首项可能在缓存区/被遮挡)→
|
||||||
|
// 关闭 → 偏移保持。
|
||||||
|
await tester.tap(find.byType(PhotoThumbnail).hitTestable().first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||||||
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.state<ScrollableState>(gridScrollable).position.pixels,
|
||||||
|
scrolled,
|
||||||
|
reason: '关闭大图后网格回到原滚动位置',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('签名 URL 失败 → 一次受控刷新(不无限重试)', (tester) async {
|
||||||
|
var listCalls = 0;
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path != '/v1/albums/alb-1/photos') {
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
}
|
||||||
|
listCalls++;
|
||||||
|
// 刷新后返回新签名 URL(测试环境网络仍失败 → 验证不再重试)。
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-url',
|
||||||
|
preview: photoPreviewJson(
|
||||||
|
browseUrl: 'http://test.local/browse-v$listCalls.jpg',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
// 初次列表 + 失败后自动刷新一次 = 2 次;再等待后不再增加。
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||||
|
expect(listCalls, 2, reason: '签名 URL 失败仅重取一次');
|
||||||
|
expect(find.text('Image unavailable'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/// 照片管理占位页 widget 测试(P3 计划 Task 4)。
|
/// 照片管理网格 widget 测试(UI-01 §4.3)。
|
||||||
///
|
///
|
||||||
/// 覆盖:Task 9 占位横幅、基础列表(可见性/处理状态标签、时区时间)、
|
/// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/
|
||||||
/// 游标加载更多。完整交互由 Task 9 替换。
|
/// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二
|
||||||
|
/// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -9,9 +10,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:photo_live/core/api_client.dart';
|
import 'package:photo_live/core/api_client.dart';
|
||||||
|
import 'package:photo_live/core/error_codes.dart';
|
||||||
import 'package:photo_live/features/albums/models.dart';
|
import 'package:photo_live/features/albums/models.dart';
|
||||||
import 'package:photo_live/l10n/app_localizations.dart';
|
import 'package:photo_live/l10n/app_localizations.dart';
|
||||||
import 'package:photo_live/pages/album_photos_page.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 '../helpers/fakes.dart';
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
@@ -31,7 +36,7 @@ void main() {
|
|||||||
Future<void> pumpPhotos(WidgetTester tester, ApiClient client) async {
|
Future<void> pumpPhotos(WidgetTester tester, ApiClient client) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
@@ -42,18 +47,134 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('占位横幅 + 列表标签 + 游标加载第二页', (tester) async {
|
void setSurface(WidgetTester tester, Size size) {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('网格列数:350→2 / 390→3 / 800→5(平板按格宽扩列)', (tester) async {
|
||||||
|
final adapter = FakeAdapter((o) {
|
||||||
|
if (o.path != '/v1/albums/alb-1/photos') {
|
||||||
|
throw StateError('unexpected ${o.method} ${o.path}');
|
||||||
|
}
|
||||||
|
// 非空列表(空态不渲染 GridView,无法断言 delegate)。
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(photoId: 'ph-c1', preview: photoPreviewJson()),
|
||||||
|
adminPhotoJson(photoId: 'ph-c2', preview: photoPreviewJson()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final client = clientOf(adapter);
|
||||||
|
|
||||||
|
setSurface(tester, const Size(350, 800));
|
||||||
|
await pumpPhotos(tester, client);
|
||||||
|
expect(_columnCount(tester), 2, reason: '极窄屏两列');
|
||||||
|
|
||||||
|
setSurface(tester, const Size(390, 844));
|
||||||
|
await pumpPhotos(tester, client);
|
||||||
|
expect(_columnCount(tester), 3, reason: '默认三列');
|
||||||
|
|
||||||
|
setSurface(tester, const Size(800, 600));
|
||||||
|
await pumpPhotos(tester, client);
|
||||||
|
expect(_columnCount(tester), 5, reason: '800/150 向下取整 5 列');
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
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-ready', preview: photoPreviewJson()),
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-processing',
|
||||||
|
processingState: 'processing',
|
||||||
|
visibility: 'unpublished',
|
||||||
|
),
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-failed',
|
||||||
|
processingState: 'processing_failed',
|
||||||
|
visibility: 'unpublished',
|
||||||
|
),
|
||||||
|
adminPhotoJson(photoId: 'ph-hidden', visibility: 'hidden'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
// 缩略图来自 preview.thumb(测试环境网络失败 → 替代图占位,不破图)。
|
||||||
|
expect(find.byType(PhotoThumbnail), findsNWidgets(4));
|
||||||
|
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||||
|
// 角标:处理中/失败/隐藏(图标 + 文案,不只靠颜色;文案限定在角标
|
||||||
|
// 组件内——筛选 chips 有同名标签)。
|
||||||
|
Finder badgeText(String label) => find.descendant(
|
||||||
|
of: find.byType(StateBadge),
|
||||||
|
matching: find.text(label),
|
||||||
|
);
|
||||||
|
expect(badgeText('Processing'), findsOneWidget);
|
||||||
|
expect(badgeText('Processing failed'), findsOneWidget);
|
||||||
|
expect(badgeText('Hidden'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.hourglass_top), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.remove_circle_outline), findsOneWidget);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('scope 自适应:all 403 → own 重试 + 协作者提示', (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}');
|
||||||
|
}
|
||||||
|
final scope = o.uri.queryParameters['scope'];
|
||||||
|
if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden));
|
||||||
|
if (scope == 'own') {
|
||||||
|
return (
|
||||||
|
200,
|
||||||
|
cursorPageBody(<Map<String, dynamic>>[
|
||||||
|
adminPhotoJson(photoId: 'ph-own-1'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw StateError('unexpected scope $scope');
|
||||||
|
});
|
||||||
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
// 两次请求:all(403)→ own(200,服务端过滤自己上传)。
|
||||||
|
expect(adapter.requests, hasLength(2));
|
||||||
|
expect(adapter.requests.first.uri.queryParameters['scope'], 'all');
|
||||||
|
expect(adapter.requests.last.uri.queryParameters['scope'], 'own');
|
||||||
|
// 协作者范围提示可见。
|
||||||
|
expect(
|
||||||
|
find.text('Collaborator view: only photos you uploaded are shown'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('游标加载第二页(携带 cursor)', (tester) async {
|
||||||
|
setSurface(tester, const Size(390, 844));
|
||||||
final page1 = List<Map<String, dynamic>>.generate(
|
final page1 = List<Map<String, dynamic>>.generate(
|
||||||
20,
|
20,
|
||||||
(i) => adminPhotoJson(
|
(i) => adminPhotoJson(
|
||||||
photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}',
|
photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}',
|
||||||
visibility: i % 2 == 0 ? 'published' : 'unpublished',
|
preview: photoPreviewJson(),
|
||||||
processingState: i % 2 == 0 ? 'ready' : 'processing',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final page2 = <Map<String, dynamic>>[
|
final page2 = <Map<String, dynamic>>[
|
||||||
// 区分度高的 ID:第二页条目渲染后可按标题前缀断言。
|
adminPhotoJson(photoId: 'ph-page2item', preview: photoPreviewJson()),
|
||||||
adminPhotoJson(photoId: 'page2item'),
|
|
||||||
];
|
];
|
||||||
final adapter = FakeAdapter((o) {
|
final adapter = FakeAdapter((o) {
|
||||||
if (o.path != '/v1/albums/alb-1/photos') {
|
if (o.path != '/v1/albums/alb-1/photos') {
|
||||||
@@ -66,29 +187,143 @@ void main() {
|
|||||||
if (cursor == 'pc1') return (200, cursorPageBody(page2));
|
if (cursor == 'pc1') return (200, cursorPageBody(page2));
|
||||||
throw StateError('unexpected cursor $cursor');
|
throw StateError('unexpected cursor $cursor');
|
||||||
});
|
});
|
||||||
|
|
||||||
await pumpPhotos(tester, clientOf(adapter));
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
|
||||||
// Task 9 占位横幅。
|
|
||||||
expect(
|
|
||||||
find.textContaining('the full experience ships in Task 9'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
// 标签与条目(ListView 懒构建,只断言在渲染的可见条目)。
|
|
||||||
expect(find.text('Published'), findsWidgets);
|
|
||||||
expect(find.text('Unpublished'), findsWidgets);
|
|
||||||
expect(find.text('Ready'), findsWidgets);
|
|
||||||
expect(find.text('Processing'), findsWidgets);
|
|
||||||
expect(find.text('2026/9/18 15:00'), findsWidgets, reason: '相册时区展示');
|
|
||||||
|
|
||||||
// 滚动加载第二页。
|
// 滚动加载第二页。
|
||||||
await tester.drag(find.byType(ListView), const Offset(0, -4000));
|
await tester.drag(find.byType(GridView), const Offset(0, -4000));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
adapter.requests.last.uri.queryParameters['cursor'],
|
adapter.requests.last.uri.queryParameters['cursor'],
|
||||||
'pc1',
|
'pc1',
|
||||||
);
|
);
|
||||||
// 第二页条目渲染(标题展示 photoId 前 8 位)。
|
|
||||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('筛选 chips:Published 走服务端 visibility 重载', (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-1', preview: photoPreviewJson()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilterChip, 'Published'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
adapter.requests.last.uri.queryParameters['visibility'],
|
||||||
|
'published',
|
||||||
|
reason: 'Published 筛选下发服务端 visibility 参数',
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('本地筛选:Processing 只显示未就绪条目(不发新请求)', (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-ready', preview: photoPreviewJson()),
|
||||||
|
adminPhotoJson(
|
||||||
|
photoId: 'ph-busy',
|
||||||
|
processingState: 'processing',
|
||||||
|
visibility: 'unpublished',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
final requestsBefore = adapter.requests.length;
|
||||||
|
|
||||||
|
// 瓦片角标文案与 chip 同名(「Processing」),按组件类型锁定 chip;
|
||||||
|
// 390 宽下 Processing/Failed 在横向滚动区外,先滚动到位。
|
||||||
|
final processingChip = find.widgetWithText(FilterChip, 'Processing');
|
||||||
|
await tester.ensureVisible(processingChip);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(processingChip);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 本地过滤:无新请求,仅剩处理中条目(含其角标)。
|
||||||
|
expect(adapter.requests, hasLength(requestsBefore));
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byType(StateBadge), matching: find.text('Processing')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byType(PhotoThumbnail), findsOneWidget);
|
||||||
|
|
||||||
|
// 本地筛选无匹配 → 空筛选态。
|
||||||
|
final failedChip = find.widgetWithText(FilterChip, 'Failed');
|
||||||
|
await tester.ensureVisible(failedChip);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(failedChip);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('No photos match this filter'), findsOneWidget);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
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>>[]));
|
||||||
|
});
|
||||||
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
expect(find.text('No photos yet'), findsOneWidget);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
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-1', preview: photoPreviewJson()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await pumpPhotos(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
await tester.tap(find.byType(PhotoThumbnail).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(AlbumPhotoViewerPage), findsNothing);
|
||||||
|
expect(find.byType(AlbumPhotosPage), findsOneWidget);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 网格列数(GridView delegate 断言用)。
|
||||||
|
int _columnCount(WidgetTester tester) =>
|
||||||
|
(tester.widget<GridView>(find.byType(GridView)).gridDelegate
|
||||||
|
as SliverGridDelegateWithFixedCrossAxisCount)
|
||||||
|
.crossAxisCount;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3 起覆盖封面卡片样板)。
|
/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3/4 覆盖封面卡片与
|
||||||
|
/// 响应式列表)。
|
||||||
///
|
///
|
||||||
/// 覆盖:封面卡片样板(封面占位/标题回退/时间场地行/公开与上传徽章/
|
/// 覆盖:封面卡片(封面占位/标题回退/时间场地行/公开与上传徽章/令牌与
|
||||||
/// 令牌与四开关移除)、关闭状态徽章、封面加载失败替代图、游标加载更多
|
/// 四开关移除)、关闭状态徽章、封面加载失败替代图、响应式单/双列、空态
|
||||||
/// (第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
|
/// CTA、游标加载更多(第二页携带 cursor)、首屏错误 + 重试恢复。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -14,6 +15,7 @@ import 'package:photo_live/core/error_codes.dart';
|
|||||||
import 'package:photo_live/features/albums/providers.dart';
|
import 'package:photo_live/features/albums/providers.dart';
|
||||||
import 'package:photo_live/l10n/app_localizations.dart';
|
import 'package:photo_live/l10n/app_localizations.dart';
|
||||||
import 'package:photo_live/pages/albums_tab.dart';
|
import 'package:photo_live/pages/albums_tab.dart';
|
||||||
|
import 'package:photo_live/pages/album_form_page.dart';
|
||||||
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||||
|
|
||||||
import '../helpers/fakes.dart';
|
import '../helpers/fakes.dart';
|
||||||
@@ -45,6 +47,12 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 固定逻辑宽度(dp=1)。
|
||||||
|
void setSurface(WidgetTester tester, Size size) {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async {
|
testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async {
|
||||||
final adapter = FakeAdapter(
|
final adapter = FakeAdapter(
|
||||||
(o) => o.path == '/v1/albums'
|
(o) => o.path == '/v1/albums'
|
||||||
@@ -101,6 +109,67 @@ void main() {
|
|||||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('响应式列数:390 宽单列 / 900 宽双列', (tester) async {
|
||||||
|
final adapter = FakeAdapter(
|
||||||
|
(o) => o.path == '/v1/albums'
|
||||||
|
? (
|
||||||
|
200,
|
||||||
|
cursorPageBody([
|
||||||
|
albumJson(id: 'alb-1'),
|
||||||
|
albumJson(id: 'alb-2'),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: throw StateError('unexpected ${o.path}'),
|
||||||
|
);
|
||||||
|
final client = clientOf(adapter);
|
||||||
|
|
||||||
|
// 手机宽度(390):单列,两卡片同宽同行不可见。
|
||||||
|
setSurface(tester, const Size(390, 844));
|
||||||
|
await pumpTab(tester, client);
|
||||||
|
final card1 = tester.getRect(find.byType(PhotoThumbnail).first);
|
||||||
|
final card2 = tester.getRect(find.byType(PhotoThumbnail).last);
|
||||||
|
expect(card1.width, closeTo(390 - 32, 0.5), reason: '单列占满(左右 16)');
|
||||||
|
expect(
|
||||||
|
card1.right == card2.right && card1.left == card2.left,
|
||||||
|
isTrue,
|
||||||
|
reason: '两卡片同列(垂直排布)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 平板宽度(900):双列并排。
|
||||||
|
setSurface(tester, const Size(900, 1200));
|
||||||
|
await pumpTab(tester, client);
|
||||||
|
final wide1 = tester.getRect(find.byType(PhotoThumbnail).first);
|
||||||
|
final wide2 = tester.getRect(find.byType(PhotoThumbnail).last);
|
||||||
|
expect(wide1.width, closeTo((900 - 32 - 12) / 2, 0.5), reason: '双列均分');
|
||||||
|
expect(
|
||||||
|
wide2.left > wide1.right,
|
||||||
|
isTrue,
|
||||||
|
reason: '第二张在第一张右侧(双列)',
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.resetPhysicalSize();
|
||||||
|
tester.view.resetDevicePixelRatio();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('空列表 → 空态文案 + 「新建相册」CTA 进入表单', (tester) async {
|
||||||
|
final adapter = FakeAdapter(
|
||||||
|
(o) => o.path == '/v1/albums'
|
||||||
|
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
||||||
|
: throw StateError('unexpected ${o.path}'),
|
||||||
|
);
|
||||||
|
await pumpTab(tester, clientOf(adapter));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('No albums yet. Tap "Create album" to get started'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// 空态 CTA(次强调按钮)→ 创建表单。
|
||||||
|
expect(find.byType(AlbumFormPage), findsNothing);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Create album'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(AlbumFormPage), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async {
|
testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async {
|
||||||
final page1 = List<Map<String, dynamic>>.generate(
|
final page1 = List<Map<String, dynamic>>.generate(
|
||||||
20,
|
20,
|
||||||
@@ -146,20 +215,6 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('空列表 → 空态文案', (tester) async {
|
|
||||||
final adapter = FakeAdapter(
|
|
||||||
(o) => o.path == '/v1/albums'
|
|
||||||
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
|
||||||
: throw StateError('unexpected ${o.path}'),
|
|
||||||
);
|
|
||||||
await pumpTab(tester, clientOf(adapter));
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.text('No albums yet. Tap "Create album" to get started'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async {
|
testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async {
|
||||||
var failFirst = true;
|
var failFirst = true;
|
||||||
final adapter = FakeAdapter((o) {
|
final adapter = FakeAdapter((o) {
|
||||||
|
|||||||
Reference in New Issue
Block a user