Files
photo-live/apps/mobile/lib/pages/album_photos_page.dart
T
2026-09-21 15:55:44 +08:00

687 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 照片管理页(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';
import '../core/api_client.dart';
import '../core/app_exception.dart';
import '../core/error_codes.dart';
import '../core/error_l10n.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';
/// 网格筛选(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});
final Album album;
@override
ConsumerState<AlbumPhotosPage> createState() => _AlbumPhotosPageState();
}
class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
PhotoGridController? _controller;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
final controller = PhotoGridController(
api: ref.read(apiClientProvider),
readSession: ref.read(sessionStoreProvider).read,
albumId: widget.album.id,
);
_controller = controller;
controller
..loadSelfAccount()
..resolveAndLoad();
}
@override
void dispose() {
_controller?.dispose();
_scrollController.dispose();
super.dispose();
}
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);
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),
),
),
],
),
),
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(
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),
);
},
),
);
},
);
}
/// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。
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 _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,
};
}
/// 网格瓦片:1:1 缩略图(cover 裁切)+ 左上角状态角标。
class _PhotoGridTile extends StatelessWidget {
const _PhotoGridTile({required this.photo, required this.onTap});
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 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,
),
),
],
),
);
}
}