feat(mobile): UI-01 album cards, photo grid and full viewer

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 15:55:44 +08:00
parent c94b9e5e58
commit f6929ae18f
17 changed files with 2750 additions and 702 deletions
+6
View File
@@ -442,10 +442,15 @@ class ApiClient {
/// operationId `listAlbumPhotos`
/// (GET /albums/{albumId}/photos,管理列表,全状态可见)。
///
/// [scope](UI-01):缺省/null=服务端默认 all(仅 owner/admin,协作者
/// 403 AUTH_FORBIDDEN);[AlbumPhotoScope.own]=仅自己上传(任何有效成员,
/// 服务端过滤)。页面层据此做「all 403 → own」自适应回退。
Future<CursorPage<AdminPhoto>> listAlbumPhotos(
String albumId, {
PhotoVisibility? visibility,
String? categoryId,
AlbumPhotoScope? scope,
String? cursor,
int? limit,
}) =>
@@ -457,6 +462,7 @@ class ApiClient {
extraQuery: {
if (visibility != null) 'visibility': visibility.wire,
'categoryId': ?categoryId,
if (scope != null) 'scope': scope.wire,
},
);
@@ -257,6 +257,7 @@ class Album extends AlbumSummary {
required super.createdAt,
required super.updatedAt,
required super.statsSyncedAt,
super.cover,
});
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>),
)
.toList(),
cover: json['cover'] == null
? null
: SignedUrl.fromJson(json['cover'] as Map<String, dynamic>),
id: json['id'] as String,
publicToken: json['publicToken'] as String,
ownerId: json['ownerId'] as String,
+18 -1
View File
@@ -177,5 +177,22 @@
"uploadAction": "Upload",
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
"@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"
}
+102
View File
@@ -1129,6 +1129,108 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'未选择照片'**
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
@@ -551,4 +551,58 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
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 => '图片暂不可用';
}
+18 -1
View File
@@ -177,5 +177,22 @@
"uploadAction": "上传",
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
"uploadPickNone": "未选择照片"
"uploadPickNone": "未选择照片",
"albumManagePhotos": "管理照片",
"albumTakeFirstPhoto": "拍摄第一张照片",
"albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片",
"albumRecentPhotos": "最近上传",
"photoFilterAll": "全部",
"photoFilterFailed": "失败",
"photosScopeOwnHint": "协作者视图:仅显示你上传的照片",
"photosEmptyFiltered": "当前筛选下暂无照片",
"photoHide": "隐藏",
"photoUnhide": "恢复显示",
"photoDeleteConfirmTitle": "删除这张照片?",
"photoDeleteConfirmBody": "云端照片将被删除,删除后不可恢复。",
"photoHiddenToast": "已隐藏",
"photoUnhiddenToast": "已恢复显示",
"photoDeletedToast": "已删除",
"photoReload": "重新加载",
"photoPreviewUnavailable": "图片暂不可用"
}
+441 -291
View File
@@ -1,7 +1,11 @@
/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签)
/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理
/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。
/// 编辑与分类变更后刷新本地详情并同步全局相册列表。
/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 →
/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/
/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传
/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
///
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。
library;
import 'package:flutter/material.dart';
@@ -14,15 +18,23 @@ import '../core/error_l10n.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../features/photos/models.dart';
import '../features/uploads/enqueue.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_members_page.dart';
import 'album_photos_page.dart';
import 'capture_page.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 {
const AlbumDetailPage({required this.albumId, super.key});
@@ -40,6 +52,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
bool _loading = true;
String? _errorCode;
/// 最近上传预览条(协作者为「自己上传」,服务端过滤)。
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
bool _photosLoading = true;
/// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。
/// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。
bool _isManager = true;
@override
void initState() {
super.initState();
@@ -61,12 +81,12 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final album = await ref.read(apiClientProvider).getAlbum(
widget.albumId,
);
if (mounted) {
setState(() {
_album = album;
_loading = false;
});
}
if (!mounted) return;
setState(() {
_album = album;
_loading = false;
});
await _loadPhotoPreview();
} on AppException catch (e) {
if (mounted) {
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 {
final album = _album;
@@ -118,6 +195,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
builder: (_) => AlbumPhotosPage(album: album),
),
);
// 管理页内可能发生隐藏/删除,回来后刷新预览条。
await _loadPhotoPreview();
}
void _shareQr() {
@@ -129,7 +208,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
);
}
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
Future<String?> _currentAccountId() async {
@@ -137,36 +216,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
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 {
final album = _album;
@@ -180,7 +229,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
target: CaptureTarget(
accountId: account,
albumId: album.id,
albumTitle: _titleOf(album),
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
categories: album.categories,
),
),
@@ -272,17 +321,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final album = _album;
final title = album == null ? l10n.albumDetail : _titleOf(album);
final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName);
return Scaffold(
appBar: AppBar(title: Text(title)),
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
floatingActionButton: album == null
? null
: FloatingActionButton.extended(
onPressed: _openUploadSheet,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(l10n.uploadAction),
),
bottomNavigationBar: album == null ? null : _UploadBar(
uploadEnabled: album.uploadEnabled,
onCapture: _openCapture,
onPick: _pickFromGallery,
),
body: _loading
? const Center(child: CircularProgressIndicator())
: album == null
@@ -292,21 +338,32 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
)
: SingleChildScrollView(
controller: _scrollController,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_InfoCard(album: album),
const SizedBox(height: 16),
_ActionsRow(
onEdit: _openEdit,
onMembers: _openMembers,
onCategories: _scrollToCategories,
onShareQr: _shareQr,
onPhotos: _openPhotos,
),
const SizedBox(height: 16),
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CompactHeader(album: album),
const SizedBox(height: kSpace16),
_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,
onMembers: _openMembers,
onCategories: _scrollToCategories,
onShareQr: _shareQr,
),
if (_isManager) ...[
const SizedBox(height: kSpace16),
_CategoriesSection(
key: _categoriesKey,
album: album,
@@ -314,20 +371,328 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
onEdit: (c) => _openCategoryDialog(c),
onDelete: _deleteCategory,
),
const SizedBox(height: 32),
],
),
const SizedBox(height: kSpace32),
],
),
),
);
}
}
static String _titleOf(Album album) {
for (final t in album.translations) {
if (t.locale == album.defaultLocale) return t.title;
}
if (album.translations.isNotEmpty) return album.translations.first.title;
return album.id;
/// 紧凑头部:封面缩略图(16:9,最高 200)+ 标题 + 时间/场地单行。
class _CompactHeader extends StatelessWidget {
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,
),
),
],
);
}
}
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
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 {
const _CategoriesSection({
@@ -594,6 +774,7 @@ class _CategoriesSection extends StatelessWidget {
)
else
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
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(对话框保持)。
@@ -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),
),
],
),
);
},
);
}
}
+624 -260
View File
@@ -1,7 +1,19 @@
/// 照片管理占位页(P3 计划 Task 4):基础管理列表(可见性/处理状态
/// 标签 + 游标加载更多);完整筛选与操作由 Task 9 队列 UI 替换。
/// 照片管理页(UI-01 §4.3 重写):三列缩略图网格(极窄两列、平板按最小
/// 格宽扩列)+ 状态角标(处理中/失败/隐藏/删除)+ 筛选 chips + 游标分页
/// + 点开大图查看器。
///
/// 数据范围(scope):先按 all 请求(owner/admin);协作者 403
/// AUTH_FORBIDDEN 时自适应回退 own(服务端过滤,仅自己上传)——isManager
/// 由此推导,供查看器操作权限(恢复发布仅管理者)使用。缩略图取条目
/// preview.thumb;签名 URL 失效经 [PhotoGridController.refreshPreview]
/// 受控重取所在页一次(不无限重试)。
///
/// Processing/Failed 筛选为已加载页的本地过滤(服务端无对应查询参数),
/// 结果不足时自动补页(上限内),不声称全量。
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -9,12 +21,397 @@ import '../core/api_client.dart';
import '../core/app_exception.dart';
import '../core/error_codes.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/photos/models.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 {
const AlbumPhotosPage({required this.album, super.key});
@@ -25,297 +422,264 @@ class AlbumPhotosPage extends ConsumerStatefulWidget {
}
class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
static const int _pageSize = 20;
List<AdminPhoto> _items = const <AdminPhoto>[];
String? _nextCursor;
bool _hasMore = false;
bool _loading = true;
bool _loadingMore = false;
String? _errorCode;
PhotoGridController? _controller;
final ScrollController _scrollController = ScrollController();
@override
void 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 {
setState(() {
_loading = true;
_errorCode = null;
});
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;
});
}
}
@override
void dispose() {
_controller?.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadMore() async {
if (_loading || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
try {
final page = await ref.read(apiClientProvider).listAlbumPhotos(
widget.album.id,
cursor: _nextCursor,
limit: _pageSize,
);
if (mounted) {
setState(() {
_items = <AdminPhoto>[..._items, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadingMore = false;
});
}
} on AppException {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).messageForErrorCode(
ErrorCodes.networkError,
),
),
),
);
}
} on Object {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).errorGeneric),
),
);
}
Future<void> _openViewer(int loadedIndex) async {
final controller = _controller;
if (controller == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => AlbumPhotoViewerPage(
controller: controller,
album: widget.album,
initialIndex: loadedIndex,
),
),
);
}
@override
Widget build(BuildContext context) {
final controller = _controller;
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,
});
final PhotoGridController controller;
final Album album;
final ScrollController scrollController;
final void Function(int loadedIndex) onOpenViewer;
/// 本地筛选自动补页上限(防 0 匹配时无限翻页;超过后显示空筛选态)。
static const int _localFilterAutoLoadCap = 100;
/// 网格列数(方案 §4.3):极窄(<360)两列;默认三列;平板按
/// width/150 向下取整扩列(不小于 3)。
static int columnCount(double width) {
if (width < 360) return 2;
return math.max(3, width ~/ 150);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.albumPhotos)),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Task 9 占位横幅:完整筛选/操作届时替换本页。
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,
if (controller.loading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.items.isEmpty && controller.errorCode != null) {
return ErrorState(
message: l10n.messageForErrorCode(controller.errorCode!),
onRetry: controller.reload,
);
}
final visible = controller.visibleItems;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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),
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.photosTask9Placeholder,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
),
if (!controller.isManager)
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace8,
kSpace16,
kSpace4,
),
child: Text(
l10n.photosScopeOwnHint,
style: Theme.of(context).textTheme.bodySmall,
),
),
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) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (index >= _items.length - 3 &&
_hasMore &&
!_loadingMore) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _loadMore(),
);
}
return _photoTile(l10n, theme, _items[index]);
},
),
),
],
),
Expanded(child: _gridOrEmpty(context, l10n, visible)),
],
);
}
Widget _photoTile(
Widget _gridOrEmpty(
BuildContext context,
AppLocalizations l10n,
ThemeData theme,
AdminPhoto photo,
List<AdminPhoto> visible,
) {
return ListTile(
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
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,
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(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
_maybeLoadMore(index, visible.length);
final photo = visible[index];
final loadedIndex = controller.items.indexOf(photo);
return _PhotoGridTile(
photo: photo,
onTap: () => onOpenViewer(loadedIndex),
);
},
),
_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;
/// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。
void _maybeLoadMore(int index, int visibleCount) {
final nearEnd = index >= visibleCount - 6;
final localStarved = controller.filter.isLocal &&
visibleCount < 9 &&
controller.items.length < _localFilterAutoLoadCap;
if ((nearEnd || localStarved) &&
controller.hasMore &&
!controller.loadingMore &&
!controller.loading) {
WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore());
}
}
static String _processingLabel(
AppLocalizations l10n,
PhotoProcessingState s,
) {
switch (s) {
case PhotoProcessingState.pendingUpload:
return l10n.procPendingUpload;
case PhotoProcessingState.uploaded:
return l10n.procUploaded;
case PhotoProcessingState.processing:
return l10n.procProcessing;
case PhotoProcessingState.ready:
return l10n.procReady;
case PhotoProcessingState.processingFailed:
return l10n.procProcessingFailed;
}
}
static String _filterLabel(AppLocalizations l10n, PhotoGridFilter filter) =>
switch (filter) {
PhotoGridFilter.all => l10n.photoFilterAll,
PhotoGridFilter.published => l10n.visibilityPublished,
PhotoGridFilter.hidden => l10n.visibilityHidden,
PhotoGridFilter.processing => l10n.procProcessing,
PhotoGridFilter.failed => l10n.photoFilterFailed,
};
}
/// 照片标签(可见性/处理状态)。
class _PhotoTag extends StatelessWidget {
const _PhotoTag({required this.label, required this.highlight});
/// 网格瓦片:1:1 缩略图(cover 裁切)+ 左上角状态角标。
class _PhotoGridTile extends StatelessWidget {
const _PhotoGridTile({required this.photo, required this.onTap});
final String label;
final bool highlight;
final AdminPhoto photo;
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: highlight
? theme.colorScheme.secondaryContainer
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: highlight
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.onSurfaceVariant,
),
final l10n = AppLocalizations.of(context);
final badge = _badge(l10n, photo);
return InkWell(
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
PhotoThumbnail(
url: photo.preview?.thumb.url,
aspectRatio: 1,
),
if (badge != null)
Positioned(
top: kSpace4,
left: kSpace4,
child: StateBadge(
variant: badge.$2,
label: badge.$1,
compact: true,
),
),
],
),
);
}
+79 -47
View File
@@ -1,8 +1,9 @@
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1 样板)。
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。
///
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。完整列表
/// 交互(平板两列、错误重试等)随后续任务迭代。
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列;
/// 可用宽度 > 600 双列(行内卡片等高,不挤压英文标题)。publicToken 与
/// 四开关堆叠不在主卡片(完整配置在详情设置)。
library;
import 'package:flutter/material.dart';
@@ -21,11 +22,15 @@ import '../ui/tokens.dart';
import 'album_detail_page.dart';
import 'album_form_page.dart';
/// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。
const double kAlbumTwoColumnMinWidth = 600;
/// 首页「相册」页签。
class AlbumsTab extends ConsumerWidget {
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>(
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
);
@@ -41,7 +46,7 @@ class AlbumsTab extends ConsumerWidget {
return Scaffold(
appBar: AppBar(title: Text(l10n.albums)),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openCreate(context, ref),
onPressed: () => openCreate(context, ref),
icon: const Icon(Icons.add),
label: Text(l10n.albumCreate),
),
@@ -51,6 +56,10 @@ class AlbumsTab extends ConsumerWidget {
}
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
///
/// 空态带「新建相册」入口;错误态带重试。列表按可用宽度切换单/双列,
/// 行结构 [ListView.builder](行 = 1-2 张卡片),保证下拉刷新与游标
/// 加载更多行为与旧版一致。
class AlbumListBody extends ConsumerWidget {
const AlbumListBody({required this.state, super.key});
@@ -74,49 +83,75 @@ class AlbumListBody extends ConsumerWidget {
return EmptyState(
icon: Icons.photo_library_outlined,
title: l10n.albumsEmpty,
actionLabel: l10n.albumCreate,
onAction: () => AlbumsTab.openCreate(context, ref),
);
}
final hasFooter = state.hasMore || state.loadMoreFailed;
return RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: state.items.length + (hasFooter ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
if (state.loadMoreFailed) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: kSpace16,
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;
return RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace8,
kSpace16,
kSpace32,
),
itemCount: rowCount + (hasFooter ? 1 : 0),
itemBuilder: (context, index) {
if (index >= rowCount) {
if (state.loadMoreFailed) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.error_outline),
title: Text(l10n.loadMoreFailed),
onTap: notifier.loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: kSpace16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
if (index >= rowCount - 2 && state.hasMore && !state.loadingMore) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => notifier.loadMore(),
);
}
if (!twoColumns) {
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])),
],
],
),
leading: const Icon(Icons.error_outline),
title: Text(l10n.loadMoreFailed),
onTap: notifier.loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: kSpace16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
if (index >= state.items.length - 3 &&
state.hasMore &&
!state.loadingMore) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => notifier.loadMore(),
);
}
return AlbumCard(album: state.items[index]);
},
),
},
),
);
},
);
}
}
@@ -140,10 +175,7 @@ class AlbumCard extends StatelessWidget {
? _timeRange(l10n)
: '${_timeRange(l10n)} · $venue';
return Card(
margin: const EdgeInsets.symmetric(
horizontal: kSpace16,
vertical: kSpace8,
),
margin: const EdgeInsets.symmetric(vertical: kSpace8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Navigator.of(context).push(