Files
photo-live/apps/mobile/lib/pages/album_detail_page.dart
T
浅念 8bd09eefb9 feat(mobile): UI-03 forms and collaboration pages (09-12)
- Page 09 create: grouped form (Event details / Description & languages),
  required default-language selector, zh-CN/en tabs with per-locale
  title/description/venue/address, explicit timezone field, bottom save
  button kept keyboard-reachable, create mode has no Access tab
- Page 10 edit: Details/Access tabs sharing one draft; Access has four
  switches with explanations; original downloads constrained by downloads
  (disabled + forced off in PATCH); unsaved-changes PopScope confirm;
  save commits all changes in one minimal PATCH
- Page 11 members: Owner group + Collaborators list with initial avatars
  and per-row remove; full-width Invite colleague opens account-search
  bottom sheet (debounced prefix search, race guard, already-added state);
  remove confirm names the member
- Page 12 categories: extracted to standalone page with per-row edit/
  delete menu and New category sheet (language tabs + name + sort order);
  delete confirm mentions photo impact; collaborator browse-only; old
  inline section removed from album detail
- l10n: new form/members/categories keys both locales; regenerated
- tests: form 14, members 6, categories 7, detail nav 1; full suite green

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 01:23:02 +08:00

846 lines
28 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-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」
/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再
/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口
/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览
/// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航);
/// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时
/// 说明原因并禁用。
///
/// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your
/// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不
/// 当绝对空白,无伪造计数与示例照片。
///
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。
library;
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 '../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_categories_page.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';
/// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。
const int _kRecentGridLimit = 9;
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
enum AlbumAction { edit, members, categories, share }
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
class AlbumDetailPage extends ConsumerStatefulWidget {
const AlbumDetailPage({required this.albumId, super.key});
final String albumId;
@override
ConsumerState<AlbumDetailPage> createState() => _AlbumDetailPageState();
}
class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
Album? _album;
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();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorCode = null;
});
try {
final album = await ref.read(apiClientProvider).getAlbum(
widget.albumId,
);
if (!mounted) return;
setState(() {
_album = album;
_loading = false;
});
await _loadPhotoPreview();
} on AppException catch (e) {
if (mounted) {
setState(() {
_errorCode = e.code;
_loading = false;
});
}
} on Object {
if (mounted) {
setState(() {
_errorCode = ErrorCodes.internalError;
_loading = false;
});
}
}
}
/// 照片入口预览:先按 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: _kRecentGridLimit,
);
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: _kRecentGridLimit,
);
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;
if (album == null) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => AlbumFormPage(existing: album),
),
);
if (changed == true) {
await _load();
ref.read(albumListProvider.notifier).refresh();
}
}
Future<void> _openMembers() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumMembersPage(album: album),
),
);
// 成员变更不影响详情数据,回来无需刷新。
}
Future<void> _openPhotos() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumPhotosPage(album: album),
),
);
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
await _loadPhotoPreview();
}
/// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情
/// 返回,供连拍目标等使用)。
Future<void> _openCategories() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumCategoriesPage(album: album, isManager: _isManager),
),
);
await _load();
}
void _shareQr() {
final album = _album;
if (album == null) return;
showDialog<void>(
context: context,
builder: (_) => ShareQrDialog(
publicToken: album.publicToken,
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
),
);
}
/// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再
/// 导航(await pop 后统一分发),避免叠层路由错乱。
Future<void> _showActionMenu() async {
final action = await showModalBottomSheet<AlbumAction>(
context: context,
showDragHandle: true,
builder: (_) => AlbumActionSheet(isManager: _isManager),
);
if (!mounted || action == null) return;
switch (action) {
case AlbumAction.edit:
await _openEdit();
case AlbumAction.members:
await _openMembers();
case AlbumAction.categories:
await _openCategories();
case AlbumAction.share:
_shareQr();
}
}
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
Future<String?> _currentAccountId() async {
final session = await ref.read(sessionStoreProvider).read();
return session?.account;
}
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
Future<void> _openCapture() async {
final album = _album;
if (album == null) return;
final account = await _currentAccountId();
if (!mounted) return;
if (account == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CapturePage(
target: CaptureTarget(
accountId: account,
albumId: album.id,
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
categories: album.categories,
),
),
),
);
}
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
Future<void> _pickFromGallery() async {
final album = _album;
if (album == null) return;
final l10n = AppLocalizations.of(context);
final account = await _currentAccountId();
if (account == null) return;
final paths = await ref.read(multiImagePickerProvider)();
if (paths.isEmpty || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final album = _album;
final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName);
return Scaffold(
appBar: AppBar(
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: l10n.moreActions,
onPressed: _showActionMenu,
icon: const Icon(Icons.more_vert),
),
],
),
bottomNavigationBar: album == null ? null : _UploadBar(
uploadEnabled: album.uploadEnabled,
onCapture: _openCapture,
onPick: _pickFromGallery,
),
body: _loading
? const Center(child: CircularProgressIndicator())
: album == null
? _ErrorRetryView(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _load,
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 封面:全宽 16:9(无封面同尺寸中性占位)。
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
albumDisplayTitle(album, l10n.localeName),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: kSpace4),
Text(
_metaLine(album, l10n),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
const SizedBox(height: kSpace12),
_StatusSection(album: album),
],
),
),
const SizedBox(height: kSpace16),
// 次级四入口(Photos/Members/Categories/Share)。
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
child: _ActionTiles(
isManager: _isManager,
onPhotos: _openPhotos,
onMembers: _openMembers,
onCategories: _openCategories,
onShare: _shareQr,
),
),
const SizedBox(height: kSpace24),
// Recent photos:三列网格(≤9)/ 空相册安静占位 /
// 处理中指示。
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
child: _RecentPhotosSection(
photos: _recentPhotos,
loading: _photosLoading,
isManager: _isManager,
onOpenPhotos: _openPhotos,
),
),
const SizedBox(height: kSpace32),
],
),
),
);
}
/// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。
static String _metaLine(Album album, AppLocalizations l10n) {
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);
return venue == null
? '$start ~ $end (${album.timezone})'
: '$start ~ $end (${album.timezone}) · $venue';
}
}
/// 操作菜单(UI-03 §4 页面 07):Edit album / Manage members /
/// Categories / Share album + Cancel,按角色过滤(管理类动作仅
/// owner/admin;分享对全体成员开放)。点击 pop 对应 [AlbumAction],
/// 由详情页在面板关闭后统一导航。
class AlbumActionSheet extends StatelessWidget {
const AlbumActionSheet({required this.isManager, super.key});
/// 是否管理者(owner/admin):决定管理类动作是否显示。
final bool isManager;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// 大字体/小屏下面板内容可滚动,不溢出。
return SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isManager) ...[
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(l10n.editAlbum),
onTap: () => Navigator.of(context).pop(AlbumAction.edit),
),
ListTile(
leading: const Icon(Icons.group_outlined),
title: Text(l10n.manageMembers),
onTap: () => Navigator.of(context).pop(AlbumAction.members),
),
ListTile(
leading: const Icon(Icons.category_outlined),
title: Text(l10n.actionCategories),
onTap: () => Navigator.of(context).pop(AlbumAction.categories),
),
],
ListTile(
leading: const Icon(Icons.share_outlined),
title: Text(l10n.shareAlbum),
onTap: () => Navigator.of(context).pop(AlbumAction.share),
),
const Divider(indent: kSpace16, endIndent: kSpace16),
ListTile(
title: Center(
child: Text(
l10n.cancel,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
onTap: () => Navigator.of(context).pop(),
),
],
),
),
);
}
}
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
class _StatusSection extends StatelessWidget {
const _StatusSection({required this.album});
final Album album;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Wrap(
spacing: kSpace8,
runSpacing: kSpace8,
children: [
StateBadge(
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
),
StateBadge(
variant:
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
),
if (album.status == AlbumStatus.offline)
StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline),
],
);
}
}
/// 次级四入口(UI-03 §4 页面 06):Photos / Members / Categories / Share
/// 等宽平铺,每格有效触控区 ≥44dp(实际高 72)。管理类入口(成员/分类)
/// 仅管理者可见;Photos 与 Share 对全体成员开放。
class _ActionTiles extends StatelessWidget {
const _ActionTiles({
required this.isManager,
required this.onPhotos,
required this.onMembers,
required this.onCategories,
required this.onShare,
});
final bool isManager;
final VoidCallback onPhotos;
final VoidCallback onMembers;
final VoidCallback onCategories;
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Row(
children: [
Expanded(
child: _ActionTile(
icon: Icons.photo_library_outlined,
label: AppLocalizations.of(context).actionPhotos,
onTap: onPhotos,
),
),
if (isManager) ...[
const VerticalDivider(width: 1),
Expanded(
child: _ActionTile(
icon: Icons.group_outlined,
label: AppLocalizations.of(context).actionMembers,
onTap: onMembers,
),
),
const VerticalDivider(width: 1),
Expanded(
child: _ActionTile(
icon: Icons.category_outlined,
label: AppLocalizations.of(context).actionCategories,
onTap: onCategories,
),
),
],
const VerticalDivider(width: 1),
Expanded(
child: _ActionTile(
icon: Icons.share_outlined,
label: AppLocalizations.of(context).actionShare,
onTap: onShare,
),
),
],
),
);
}
}
/// 单个入口格:圆角容器图标 + 标签纵排(≥44dp 触控要求)。
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 72),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Icon(
icon,
size: 22,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: kSpace8),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
/// Recent photos(UI-03 §4 页面 06/08):
/// - 加载中 → 占位高度居中转圈;
/// - 有照片 → 三列网格(≤9 张,真实缩略图;URL 失败为中性占位),
/// 其中存在服务端处理中的照片时在网格上方提示「Processing...」;
/// - 无照片 → 安静空态(页面 08):图标 + 「Ready for your first photo」,
/// 协作者显示只读说明;不显示伪造计数与示例照片。
class _RecentPhotosSection extends StatelessWidget {
const _RecentPhotosSection({
required this.photos,
required this.loading,
required this.isManager,
required this.onOpenPhotos,
});
final List<AdminPhoto> photos;
final bool loading;
final bool isManager;
final VoidCallback onOpenPhotos;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionHeader(
title: l10n.recentPhotos,
trailing: TextButton(
onPressed: onOpenPhotos,
child: Text(l10n.seeAllPhotos),
),
),
const SizedBox(height: kSpace8),
if (loading)
const SizedBox(
height: 120,
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (photos.isNotEmpty) ...[
if (_hasProcessing)
Padding(
padding: const EdgeInsets.only(bottom: kSpace8),
child: Row(
children: [
Icon(
Icons.hourglass_top,
size: 16,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: kSpace8),
Text(
l10n.processingPhotos,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: kSpace8,
crossAxisSpacing: kSpace8,
),
itemCount: photos.length,
itemBuilder: (context, index) => PhotoThumbnail(
url: photos[index].preview?.thumb.url,
aspectRatio: 1,
),
),
]
else
Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace24),
child: Column(
children: [
Icon(
Icons.photo_camera_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: kSpace12),
Text(
l10n.emptyAlbumTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: kSpace4),
Text(
isManager ? l10n.emptyAlbumSubtitle : l10n.readonlyMember,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
bool get _hasProcessing => photos.any(
(p) => p.processingState == PhotoProcessingState.processing,
);
}
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
/// 关闭时给出原因说明并禁用两个入口。
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),
),
),
],
),
),
],
),
);
}
}
/// 首屏错误 + 重试。
class _ErrorRetryView extends StatelessWidget {
const _ErrorRetryView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
),
);
}
}