Files
photo-live/apps/mobile/lib/pages/album_detail_page.dart
T

1168 lines
37 KiB
Dart
Raw Normal View History

/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」
/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再
/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口
/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览
/// (≤9 张)+ See all →(管理者)分类管理区;底部固定「拍摄上传(主)/
/// 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
///
/// 空相册(页面 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_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> {
final GlobalKey _categoriesKey = GlobalKey();
final ScrollController _scrollController = ScrollController();
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();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
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();
}
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:
_scrollToCategories();
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))),
);
}
void _scrollToCategories() {
final ctx = _categoriesKey.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
Future<void> _deleteCategory(Category category) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.delete),
content: Text(l10n.deleteConfirmCategory),
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 ref
.read(apiClientProvider)
.deleteCategory(category.id);
await _load();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
Future<void> _openCategoryDialog([Category? existing]) async {
final album = _album;
if (album == null) return;
final changed = await showDialog<bool>(
context: context,
builder: (_) => CategoryEditDialog(albumId: album.id, existing: existing),
);
if (changed == true) await _load();
}
@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(
controller: _scrollController,
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: _scrollToCategories,
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,
),
),
if (_isManager) ...[
const SizedBox(height: kSpace24),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
child: _CategoriesSection(
key: _categoriesKey,
album: album,
onAdd: () => _openCategoryDialog(),
onEdit: (c) => _openCategoryDialog(c),
onDelete: _deleteCategory,
),
),
],
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),
),
],
),
);
}
}
/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。
class _CategoriesSection extends StatelessWidget {
const _CategoriesSection({
super.key,
required this.album,
required this.onAdd,
required this.onEdit,
required this.onDelete,
});
final Album album;
final VoidCallback onAdd;
final void Function(Category category) onEdit;
final Future<void> Function(Category category) onDelete;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.albumCategories,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
IconButton(
tooltip: l10n.categoryAdd,
onPressed: onAdd,
icon: const Icon(Icons.add_circle_outline),
),
],
),
if (album.categories.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.categoryEmpty,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
)
else
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (final (i, category) in album.categories.indexed) ...[
if (i > 0) const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
dense: true,
leading: SizedBox(
width: 40,
child: Text(
'${category.sortOrder}',
textAlign: TextAlign.center,
style: theme.textTheme.labelMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
title: Text(_namesOf(category)),
onTap: () => onEdit(category),
trailing: IconButton(
tooltip: l10n.delete,
icon: const Icon(Icons.delete_outline),
onPressed: () => onDelete(category),
),
),
],
],
),
),
],
);
}
/// 分类名展示:zh / en 名称以「 · 」连接。
static String _namesOf(Category category) {
final names = category.translations.map((t) => t.name).toList();
return names.isEmpty ? category.id : names.join(' · ');
}
}
/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。
///
/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。
class CategoryEditDialog extends ConsumerStatefulWidget {
const CategoryEditDialog({
required this.albumId,
this.existing,
super.key,
});
final String albumId;
/// 编辑目标;null = 新增。
final Category? existing;
@override
ConsumerState<CategoryEditDialog> createState() => _CategoryEditDialogState();
}
class _CategoryEditDialogState extends ConsumerState<CategoryEditDialog> {
late final TextEditingController _sortOrder =
TextEditingController(text: (widget.existing?.sortOrder ?? 0).toString());
late final TextEditingController _zhName = TextEditingController(
text: widget.existing?.translations
.where((t) => t.locale == 'zh-CN')
.firstOrNull
?.name ??
'',
);
late final TextEditingController _enName = TextEditingController(
text: widget.existing?.translations
.where((t) => t.locale == 'en')
.firstOrNull
?.name ??
'',
);
String? _error;
bool _submitting = false;
@override
void dispose() {
_sortOrder.dispose();
_zhName.dispose();
_enName.dispose();
super.dispose();
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context);
final zh = _zhName.text.trim();
final en = _enName.text.trim();
if (zh.isEmpty && en.isEmpty) {
setState(() => _error = l10n.categoryNameRequired);
return;
}
final sortOrder = int.tryParse(_sortOrder.text.trim());
if (sortOrder == null) {
setState(() => _error = l10n.categorySortOrderInvalid);
return;
}
setState(() {
_error = null;
_submitting = true;
});
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final translations = <CategoryTranslation>[
if (zh.isNotEmpty) CategoryTranslation(locale: 'zh-CN', name: zh),
if (en.isNotEmpty) CategoryTranslation(locale: 'en', name: en),
];
try {
final api = ref.read(apiClientProvider);
if (widget.existing == null) {
await api.createCategory(
widget.albumId,
CategoryCreateRequest(
sortOrder: sortOrder,
translations: translations,
),
);
} else {
await api.updateCategory(
widget.existing!.id,
CategoryUpdateRequest(
sortOrder: sortOrder,
translations: translations,
),
);
}
navigator.pop(true);
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
if (mounted) setState(() => _submitting = false);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(widget.existing == null ? l10n.categoryAdd : l10n.categoryEdit),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _sortOrder,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l10n.categorySortOrder,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _zhName,
decoration: InputDecoration(
labelText: l10n.categoryNameZh,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _enName,
decoration: InputDecoration(
labelText: l10n.categoryNameEn,
border: const OutlineInputBorder(),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
actions: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.save),
),
],
);
}
}