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>
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再
|
||||
/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口
|
||||
/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览
|
||||
/// (≤9 张)+ See all →(管理者)分类管理区;底部固定「拍摄上传(主)/
|
||||
/// 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
|
||||
/// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航);
|
||||
/// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时
|
||||
/// 说明原因并禁用。
|
||||
///
|
||||
/// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your
|
||||
/// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不
|
||||
@@ -31,6 +32,7 @@ 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';
|
||||
@@ -54,9 +56,6 @@ class AlbumDetailPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final GlobalKey _categoriesKey = GlobalKey();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
Album? _album;
|
||||
bool _loading = true;
|
||||
String? _errorCode;
|
||||
@@ -75,12 +74,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -210,6 +203,19 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
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;
|
||||
@@ -237,7 +243,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
case AlbumAction.members:
|
||||
await _openMembers();
|
||||
case AlbumAction.categories:
|
||||
_scrollToCategories();
|
||||
await _openCategories();
|
||||
case AlbumAction.share:
|
||||
_shareQr();
|
||||
}
|
||||
@@ -294,64 +300,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -381,7 +329,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
onRetry: _load,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -423,7 +370,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
isManager: _isManager,
|
||||
onPhotos: _openPhotos,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _scrollToCategories,
|
||||
onCategories: _openCategories,
|
||||
onShare: _shareQr,
|
||||
),
|
||||
),
|
||||
@@ -441,21 +388,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
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),
|
||||
],
|
||||
),
|
||||
@@ -911,257 +843,3 @@ class _ErrorRetryView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user