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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user