feat(mobile): UI-03 photos and share pages (17-20)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 02:45:34 +08:00
parent ab51616c70
commit 8ee69598bf
14 changed files with 1338 additions and 379 deletions
+128 -39
View File
@@ -1,6 +1,7 @@
/// 照片管理页(UI-01 §4.3 重写):三列缩略图网格(极窄两列、平板按最小
/// 格宽扩列)+ 状态角标(处理中/失败/隐藏/删除)+ 筛选 chips + 游标分页
/// + 点开大图查看器。
/// 照片管理页(UI-01 §4.3 重写;UI-03 §4 页面 17 重设计):标题「N 张
/// 照片」+ 相册名副标题 + 筛选 chips(选中=主色蓝)+ 三列缩略图网格
/// (极窄两列、平板按最小格宽扩列;cover 裁切 + 圆角)+ 每张至多一个
/// 状态角标(图标+短标签)+ 游标分页 + 点开大图查看器。
///
/// 数据范围(scope):先按 all 请求(owner/admin);协作者 403
/// AUTH_FORBIDDEN 时自适应回退 own(服务端过滤,仅自己上传)——isManager
@@ -9,7 +10,8 @@
/// 受控重取所在页一次(不无限重试)。
///
/// Processing/Failed 筛选为已加载页的本地过滤(服务端无对应查询参数),
/// 结果不足时自动补页(上限内),不声称全量。
/// 结果不足时自动补页(上限内),不声称全量。标题计数为当前筛选下已
/// 加载条目数(游标接口无总量,不伪造总数)。
library;
import 'dart:math' as math;
@@ -23,6 +25,7 @@ import '../core/error_codes.dart';
import '../core/error_l10n.dart';
import '../core/models/cursor_page.dart';
import '../core/session_store.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/photos/models.dart';
import '../l10n/app_localizations.dart';
@@ -467,17 +470,45 @@ class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
if (controller == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).albumPhotos)),
body: AnimatedBuilder(
animation: controller,
builder: (context, _) => _AlbumPhotosBody(
controller: controller,
album: widget.album,
scrollController: _scrollController,
onOpenViewer: _openViewer,
),
),
// 整个 Scaffold 随 controller 重建:标题计数随加载/筛选变化。
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context);
final count = controller.visibleItems.length;
return Scaffold(
appBar: AppBar(
title: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 双行标题需显式收窄字阶(AppBar 标题区高 48,默认
// titleLarge 24 会溢出)。
Text(
count > 0 ? l10n.photosTitle(count) : l10n.albumPhotos,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
albumDisplayTitle(widget.album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
body: _AlbumPhotosBody(
controller: controller,
album: widget.album,
scrollController: _scrollController,
onOpenViewer: _openViewer,
),
);
},
);
}
}
@@ -530,8 +561,8 @@ class _AlbumPhotosBody extends StatelessWidget {
for (final filter in PhotoGridFilter.values)
Padding(
padding: const EdgeInsets.only(right: kSpace8),
child: FilterChip(
label: Text(_filterLabel(l10n, filter)),
child: _PhotoFilterChip(
label: _filterLabel(l10n, filter),
selected: controller.filter == filter,
onSelected: (_) => controller.setFilter(filter),
),
@@ -586,6 +617,15 @@ class _AlbumPhotosBody extends StatelessWidget {
itemCount: visible.length + (controller.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= visible.length) {
// 尾格:加载中转圈 / 失败可点重试(保留已加载条目)。
if (controller.loadMoreFailed) {
return Center(
child: TextButton(
onPressed: controller.loadMore,
child: Text(l10n.loadMoreFailed),
),
);
}
return const Center(
child: SizedBox(
height: 24,
@@ -598,6 +638,7 @@ class _AlbumPhotosBody extends StatelessWidget {
final photo = visible[index];
final loadedIndex = controller.items.indexOf(photo);
return _PhotoGridTile(
key: ValueKey('grid-photo-${photo.photoId}'),
photo: photo,
onTap: () => onOpenViewer(loadedIndex),
);
@@ -608,7 +649,9 @@ class _AlbumPhotosBody extends StatelessWidget {
);
}
/// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。
/// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。翻页
/// 失败后停止自动重试(尾格提供显式重试入口,避免滚动时反复打失败
/// 请求)。
void _maybeLoadMore(int index, int visibleCount) {
final nearEnd = index >= visibleCount - 6;
final localStarved = controller.filter.isLocal &&
@@ -617,7 +660,8 @@ class _AlbumPhotosBody extends StatelessWidget {
if ((nearEnd || localStarved) &&
controller.hasMore &&
!controller.loadingMore &&
!controller.loading) {
!controller.loading &&
!controller.loadMoreFailed) {
WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore());
}
}
@@ -632,14 +676,55 @@ class _AlbumPhotosBody extends StatelessWidget {
};
}
/// 网格瓦片:1:1 缩略图(cover 裁切)+ 左上角状态角标。
/// 筛选 chip(UI-03 §4 页面 17):选中 = 主色蓝底 + 对比文字;未选中 =
/// 表面底 + 轻分隔线边框。去 checkmark(选中态以底色表达)。
class _PhotoFilterChip extends StatelessWidget {
const _PhotoFilterChip({
required this.label,
required this.selected,
required this.onSelected,
});
final String label;
final bool selected;
final ValueChanged<bool> onSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FilterChip(
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: onSelected,
backgroundColor: theme.colorScheme.surface,
side: BorderSide(
color: selected
? theme.colorScheme.primary
: (theme.dividerTheme.color ?? kColorDivider),
),
selectedColor: theme.colorScheme.primary,
labelStyle: theme.textTheme.labelLarge?.copyWith(
color: selected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(horizontal: kSpace8),
);
}
}
/// 网格瓦片:1:1 缩略图(cover 裁切、圆角 6)+ 左上角单个状态角标。
class _PhotoGridTile extends StatelessWidget {
const _PhotoGridTile({required this.photo, required this.onTap});
const _PhotoGridTile({super.key, required this.photo, required this.onTap});
final AdminPhoto photo;
final VoidCallback onTap;
/// 角标裁决(优先级:删除 > 失败 > 隐藏 > 处理中;就绪且公开无角标)。
/// 缩略图圆角(方案 §4.3:4-6dp)。
static const double _tileRadius = 6;
/// 角标裁决(优先级:删除 > 失败 > 隐藏 > 处理中;就绪且公开无角标;
/// 每张至多一个,不叠多个冗余徽章)。
static (String, BadgeVariant)? _badge(AppLocalizations l10n, AdminPhoto p) {
if (p.visibility == PhotoVisibility.deleted) {
return (l10n.visibilityDeleted, BadgeVariant.neutral);
@@ -662,24 +747,28 @@ class _PhotoGridTile extends StatelessWidget {
final badge = _badge(l10n, photo);
return InkWell(
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
PhotoThumbnail(
url: photo.preview?.thumb.url,
aspectRatio: 1,
),
if (badge != null)
Positioned(
top: kSpace4,
left: kSpace4,
child: StateBadge(
variant: badge.$2,
label: badge.$1,
compact: true,
),
borderRadius: BorderRadius.circular(_tileRadius),
child: ClipRRect(
borderRadius: BorderRadius.circular(_tileRadius),
child: Stack(
fit: StackFit.expand,
children: [
PhotoThumbnail(
url: photo.preview?.thumb.url,
aspectRatio: 1,
),
],
if (badge != null)
Positioned(
top: kSpace4,
left: kSpace4,
child: StateBadge(
variant: badge.$2,
label: badge.$1,
compact: true,
),
),
],
),
),
);
}