2026-09-19 01:56:38 +08:00
|
|
|
/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签)
|
|
|
|
|
/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理
|
|
|
|
|
/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。
|
|
|
|
|
/// 编辑与分类变更后刷新本地详情并同步全局相册列表。
|
|
|
|
|
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';
|
2026-09-19 04:47:31 +08:00
|
|
|
import '../features/uploads/enqueue.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
import '../l10n/app_localizations.dart';
|
|
|
|
|
import 'album_form_page.dart';
|
|
|
|
|
import 'album_members_page.dart';
|
|
|
|
|
import 'album_photos_page.dart';
|
2026-09-19 04:47:31 +08:00
|
|
|
import 'capture_page.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
import 'share_qr_dialog.dart';
|
|
|
|
|
|
|
|
|
|
/// 相册详情([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;
|
|
|
|
|
|
|
|
|
|
@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) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_album = album;
|
|
|
|
|
_loading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} on AppException catch (e) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_errorCode = e.code;
|
|
|
|
|
_loading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} on Object {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_errorCode = ErrorCodes.internalError;
|
|
|
|
|
_loading = 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),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _shareQr() {
|
|
|
|
|
final album = _album;
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
showDialog<void>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (_) => ShareQrDialog(publicToken: album.publicToken),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 04:47:31 +08:00
|
|
|
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
|
|
|
|
|
|
|
|
|
|
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
|
|
|
|
Future<String?> _currentAccountId() async {
|
|
|
|
|
final session = await ref.read(sessionStoreProvider).read();
|
|
|
|
|
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;
|
|
|
|
|
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: _titleOf(album),
|
|
|
|
|
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))),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
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 : _titleOf(album);
|
|
|
|
|
return Scaffold(
|
|
|
|
|
appBar: AppBar(title: Text(title)),
|
2026-09-19 04:47:31 +08:00
|
|
|
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
|
|
|
|
|
floatingActionButton: album == null
|
|
|
|
|
? null
|
|
|
|
|
: FloatingActionButton.extended(
|
|
|
|
|
onPressed: _openUploadSheet,
|
|
|
|
|
icon: const Icon(Icons.photo_camera_outlined),
|
|
|
|
|
label: Text(l10n.uploadAction),
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
body: _loading
|
|
|
|
|
? const Center(child: CircularProgressIndicator())
|
|
|
|
|
: album == null
|
|
|
|
|
? _ErrorRetryView(
|
|
|
|
|
message: l10n.messageForErrorCode(_errorCode!),
|
|
|
|
|
onRetry: _load,
|
|
|
|
|
)
|
|
|
|
|
: 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),
|
|
|
|
|
_CategoriesSection(
|
|
|
|
|
key: _categoriesKey,
|
|
|
|
|
album: album,
|
|
|
|
|
onAdd: () => _openCategoryDialog(),
|
|
|
|
|
onEdit: (c) => _openCategoryDialog(c),
|
|
|
|
|
onDelete: _deleteCategory,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 首屏错误 + 重试。
|
|
|
|
|
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 _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({
|
|
|
|
|
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(
|
|
|
|
|
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(' · ');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 状态徽标(详情信息卡用)。
|
|
|
|
|
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(对话框保持)。
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|