1f67b7c818
Co-Authored-By: Claude Code <noreply@anthropic.com>
985 lines
30 KiB
Dart
985 lines
30 KiB
Dart
/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 →
|
||
/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/
|
||
/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传
|
||
/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
|
||
///
|
||
/// 数据范围:照片预览按 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「3-5 张缩略图」)。
|
||
const int _kPreviewStripLimit = 5;
|
||
|
||
/// 相册详情([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: _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;
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
|
||
|
||
/// 当前登录账号(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)),
|
||
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,
|
||
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,
|
||
onAdd: () => _openCategoryDialog(),
|
||
onEdit: (c) => _openCategoryDialog(c),
|
||
onDelete: _deleteCategory,
|
||
),
|
||
],
|
||
const SizedBox(height: kSpace32),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 紧凑头部:封面缩略图(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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 首屏错误 + 重试。
|
||
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),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|