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:
浅念
2026-09-22 01:23:02 +08:00
parent 5715c608d8
commit 8bd09eefb9
13 changed files with 2322 additions and 1184 deletions
+250 -123
View File
@@ -1,12 +1,19 @@
/// 相册创建/编辑表单页(UI-01 §4.5 分组重构,P3 计划 Task 4 基础)。
/// 相册创建/编辑表单页(UI-03 §4 页面 09-10 重设计)。
///
/// 创建(页面 09):两分组(SectionHeader)——「Event details」(开始/
/// 结束时间 + 显式可见时区字段,缺省 Asia/Shanghai,不静默取设备时区)+
/// 「Description & languages」(默认语言必选 + zh-CN·en 双语 tab:标题/
/// 描述/场地/地址按语言各一份——契约 translations 的场地/地址是逐语言字
/// 段,不合并为共享输入);无 Access 页签(开关取契约缺省)。
///
/// 编辑(页面 10):顶部 Details / Access 页签共享同一份草稿——Details
/// 复用创建字段;Access 为四开关(upload/public/download/original)各配
/// 说明,原图下载受总下载开关约束(关闭时禁用且生效值为 false);切页签
/// 不丢草稿、不写服务器。保存按钮固定 bottomNavigationBar——键盘打开时
/// 常驻可达;未保存离开经 PopScope 放弃确认。
///
/// 三分组(SectionHeader):活动信息(开始/结束时间 + 时区)/ 多语言
/// 内容(zh-CN · en 双语 tab:标题/描述/场地/地址按语言各一份——契约
/// translations 的场地/地址是逐语言字段,不合并为共享输入)/ 访问设置
/// (默认语言 + 开关组:创建仅 download/original,编辑四开关独立)。
/// 保存按钮固定在 bottomNavigationBar——键盘打开时常驻可达(§4.5)。
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。
library;
import 'package:flutter/material.dart';
@@ -36,7 +43,8 @@ class AlbumFormPage extends ConsumerStatefulWidget {
ConsumerState<AlbumFormPage> createState() => _AlbumFormPageState();
}
class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
with TickerProviderStateMixin {
static const String _zh = 'zh-CN';
static const String _en = 'en';
@@ -60,7 +68,17 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
late bool _downloadEnabled;
late bool _originalDownloadEnabled;
int _tabIndex = 0;
/// 进入时的初始草稿快照(未保存变更判定基准)。
late final AlbumFormDraft _initial;
/// 语言 tab(Details 内 zh-CN/en 字段切换)。
late final TabController _langTabs;
int _langIndex = 0;
/// Details/Access 主页签(仅编辑模式创建;创建模式无 Access)。
TabController? _mainTabs;
int _mainIndex = 0;
List<AlbumFormError> _errors = const <AlbumFormError>[];
bool _submitting = false;
@@ -78,6 +96,7 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
timezone: 'Asia/Shanghai',
))
: (widget.initialDraft ?? AlbumFormDraft.fromAlbum(widget.existing!));
_initial = initial;
_zhTitle = TextEditingController(text: initial.zh.title);
_zhDescription = TextEditingController(text: initial.zh.description);
_zhVenue = TextEditingController(text: initial.zh.venue);
@@ -105,10 +124,19 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
_publicEnabled = initial.publicEnabled;
_downloadEnabled = initial.downloadEnabled;
_originalDownloadEnabled = initial.originalDownloadEnabled;
_langTabs = TabController(length: 2, vsync: this);
// 仅编辑模式有主页签;内容区按 _mainIndex 手动切换(非 TabBarView),
// 页签往返不重建字段——草稿共享、焦点不丢。
if (!_isCreate) {
_mainTabs = TabController(length: 2, vsync: this);
}
}
@override
void dispose() {
_langTabs.dispose();
_mainTabs?.dispose();
for (final c in _controllers) {
c.dispose();
}
@@ -137,9 +165,36 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
uploadEnabled: _uploadEnabled,
publicEnabled: _publicEnabled,
downloadEnabled: _downloadEnabled,
originalDownloadEnabled: _originalDownloadEnabled,
// 原图下载受总下载开关约束:下载关闭时生效值恒为 false。
originalDownloadEnabled: _downloadEnabled && _originalDownloadEnabled,
);
/// 未保存变更判定:编辑 = 与原相册有差异(PATCH 非空);创建 = 与初始
/// 草稿有任何字段差异(用户实际输入过内容才算脏)。
bool get _isDirty {
if (_isCreate) {
final current = _currentDraft();
final init = _initial;
return current.zh.title != init.zh.title ||
current.zh.description != init.zh.description ||
current.zh.venue != init.zh.venue ||
current.zh.address != init.zh.address ||
current.en.title != init.en.title ||
current.en.description != init.en.description ||
current.en.venue != init.en.venue ||
current.en.address != init.en.address ||
current.defaultLocale != init.defaultLocale ||
current.timezone != init.timezone ||
current.startAt != init.startAt ||
current.endAt != init.endAt ||
current.uploadEnabled != init.uploadEnabled ||
current.publicEnabled != init.publicEnabled ||
current.downloadEnabled != init.downloadEnabled ||
current.originalDownloadEnabled != init.originalDownloadEnabled;
}
return _currentDraft().toUpdatePatch(widget.existing!) != null;
}
/// 编辑模式:与原值无任何差异时禁用保存(PATCH minProperties 1)。
bool get _canSubmit {
if (_submitting) return false;
@@ -151,7 +206,15 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
final draft = _currentDraft();
final errors = draft.validate();
if (errors.isNotEmpty) {
setState(() => _errors = errors);
// Details 字段校验失败:跳回 Details 页签展示错误(Access 页签无
// 可校验字段)。
setState(() {
_errors = errors;
if (_mainTabs != null && _mainIndex != 0) {
_mainTabs!.index = 0;
_mainIndex = 0;
}
});
return;
}
setState(() {
@@ -176,6 +239,7 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
}
navigator.pop(true);
} on AppException catch (error) {
// 保存失败:保留输入,仅提示。
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(error))),
);
@@ -186,6 +250,31 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
}
}
/// 未保存变更 → 放弃确认;确认后直接 pop(绕过 PopScope 拦截)。
Future<void> _confirmDiscard() async {
final l10n = AppLocalizations.of(context);
final discard = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.unsavedChangesTitle),
content: Text(l10n.unsavedChangesBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.keepEditing),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.discardChanges),
),
],
),
);
if (discard == true && mounted) {
Navigator.of(context).pop();
}
}
/// 日期 + 时间选择器 → 相册时区墙钟 → UTC 瞬间。
Future<void> _pickDateTime({required bool isStart}) async {
final tzName = _timezone.text.trim();
@@ -223,17 +312,33 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// DefaultTabController 仅为 TabBar 提供 controller(内容区非
// TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。
return DefaultTabController(
length: 2,
final mainTabs = _mainTabs;
return PopScope(
// 有未保存变更时拦截系统返回/aybePop;保存成功后的程序化 pop 不受
// 影响(Navigator.pop 绕过 canPop)。
canPop: !_isDirty,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_confirmDiscard();
},
child: Scaffold(
appBar: AppBar(
title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit),
title: Text(_isCreate ? l10n.createAlbumTitle : l10n.editAlbumTitle),
// 编辑模式:Details / Access 页签(共享同一草稿)。
bottom: mainTabs == null
? null
: TabBar(
controller: mainTabs,
onTap: (index) => setState(() => _mainIndex = index),
tabs: [
Tab(text: l10n.formDetailsTab),
Tab(text: l10n.formAccessTab),
],
),
),
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
minimum: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace12),
child: FilledButton(
onPressed: _canSubmit ? _submit : null,
child: _submitting
@@ -242,124 +347,146 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isCreate ? l10n.create : l10n.save),
: Text(_isCreate ? l10n.createAlbumSubmit : l10n.saveChanges),
),
),
body: Form(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 分组 1:活动信息 ────────────────────────────────
SectionHeader(title: l10n.albumSectionEventInfo),
const SizedBox(height: kSpace12),
_dateTimeRow(isStart: true),
_dateTimeRow(isStart: false),
const SizedBox(height: 8),
TextFormField(
controller: _timezone,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(labelText: l10n.albumTimezone),
),
const SizedBox(height: kSpace24),
body: mainTabs == null
? _detailsForm(l10n)
: _mainIndex == 0
? _detailsForm(l10n)
: _accessPanel(l10n),
),
);
}
// ── 分组 2:多语言内容(tab 切换,另一语言按需填写)──
SectionHeader(title: l10n.albumSectionContent),
const SizedBox(height: kSpace8),
TabBar(
onTap: (index) => setState(() => _tabIndex = index),
tabs: [
Tab(text: l10n.localeChinese),
Tab(text: l10n.localeEnglish),
],
),
const SizedBox(height: kSpace12),
if (_tabIndex == 0)
_translationFields(
title: _zhTitle,
description: _zhDescription,
venue: _zhVenue,
address: _zhAddress,
isDefault: _defaultLocale == _zh,
)
else
_translationFields(
title: _enTitle,
description: _enDescription,
venue: _enVenue,
address: _enAddress,
isDefault: _defaultLocale == _en,
),
const SizedBox(height: kSpace24),
/// Details(= 创建表单):分组字段 + 校验错误汇总。
Widget _detailsForm(AppLocalizations l10n) {
return Form(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 分组 1:Event details ──────────────────────────
SectionHeader(title: l10n.formEventDetails),
const SizedBox(height: kSpace12),
_dateTimeRow(isStart: true),
_dateTimeRow(isStart: false),
const SizedBox(height: kSpace8),
TextFormField(
controller: _timezone,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(labelText: l10n.albumTimezone),
),
const SizedBox(height: kSpace24),
// ── 分组 3:访问设置 ────────────────────────────────
SectionHeader(title: l10n.albumSectionAccess),
const SizedBox(height: kSpace12),
Text(l10n.albumDefaultLocale),
const SizedBox(height: kSpace8),
SegmentedButton<String>(
segments: [
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
],
selected: {_defaultLocale},
onSelectionChanged: (selection) =>
setState(() => _defaultLocale = selection.first),
),
const SizedBox(height: kSpace16),
if (!_isCreate) ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.switchUpload),
value: _uploadEnabled,
onChanged: (v) => setState(() => _uploadEnabled = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.switchPublic),
value: _publicEnabled,
onChanged: (v) => setState(() => _publicEnabled = v),
),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.switchDownload),
value: _downloadEnabled,
onChanged: (v) => setState(() => _downloadEnabled = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.switchOriginalDownload),
value: _originalDownloadEnabled,
onChanged: (v) =>
setState(() => _originalDownloadEnabled = v),
),
if (_errors.isNotEmpty) ...[
const SizedBox(height: kSpace8),
..._errors.map(
(e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_errorText(l10n, e),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
),
],
const SizedBox(height: kSpace24),
// ── 分组 2:Description & languages ───────────────
SectionHeader(title: l10n.formDescriptionLanguages),
const SizedBox(height: kSpace12),
Text('${l10n.formDefaultLanguage} *'),
const SizedBox(height: kSpace8),
SegmentedButton<String>(
segments: [
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
],
selected: {_defaultLocale},
onSelectionChanged: (selection) =>
setState(() => _defaultLocale = selection.first),
),
const SizedBox(height: kSpace12),
TabBar(
controller: _langTabs,
onTap: (index) => setState(() => _langIndex = index),
tabs: [
Tab(text: l10n.localeChinese),
Tab(text: l10n.localeEnglish),
],
),
),
const SizedBox(height: kSpace12),
if (_langIndex == 0)
_translationFields(
title: _zhTitle,
description: _zhDescription,
venue: _zhVenue,
address: _zhAddress,
isDefault: _defaultLocale == _zh,
)
else
_translationFields(
title: _enTitle,
description: _enDescription,
venue: _enVenue,
address: _enAddress,
isDefault: _defaultLocale == _en,
),
if (_errors.isNotEmpty) ...[
const SizedBox(height: kSpace8),
..._errors.map(
(e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_errorText(l10n, e),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
),
],
const SizedBox(height: kSpace24),
],
),
),
),
);
}
/// Access 页签(仅编辑):四开关各配说明;原图下载受总下载约束。
Widget _accessPanel(AppLocalizations l10n) {
final theme = Theme.of(context);
final descStyle = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return ListView(
padding: const EdgeInsets.all(kSpace16),
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.formAllowUploads),
subtitle: Text(l10n.formAllowUploadsDesc, style: descStyle),
value: _uploadEnabled,
onChanged: (v) => setState(() => _uploadEnabled = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.formPublicAccess),
subtitle: Text(l10n.formPublicAccessDesc, style: descStyle),
value: _publicEnabled,
onChanged: (v) => setState(() => _publicEnabled = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.formAllowDownloads),
subtitle: Text(l10n.formAllowDownloadsDesc, style: descStyle),
value: _downloadEnabled,
onChanged: (v) => setState(() => _downloadEnabled = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.formOriginalDownloads),
subtitle: Text(l10n.formOriginalDownloadsDesc, style: descStyle),
// 总下载关闭 → 原图下载禁用(生效值恒 false,重开下载恢复原值)。
value: _downloadEnabled && _originalDownloadEnabled,
onChanged: !_downloadEnabled
? null
: (v) => setState(() => _originalDownloadEnabled = v),
),
],
);
}
Widget _translationFields({
required TextEditingController title,
required TextEditingController description,