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
+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(对话框保持)。