feat(mobile): UI-01 upload center, capture and auxiliary pages

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 17:20:43 +08:00
parent f6929ae18f
commit 1f67b7c818
26 changed files with 2144 additions and 771 deletions
+128 -124
View File
@@ -1,9 +1,10 @@
/// 相册创建/编辑表单页(P3 计划 Task 4)。
/// 相册创建/编辑表单页(UI-01 §4.5 分组重构,P3 计划 Task 4 基础)。
///
/// zh-CN / en 双语 tab(标题/描述/场地/地址)、默认语言分段选择、
/// 日期时间选择器(开始必填;结束可选、可显式清除)、时区文本(缺省
/// Asia/Shanghai)、开关组:创建仅 download/original(契约缺省
/// upload/public=true 不可创建时覆盖),编辑为四开关独立。
/// 三分组(SectionHeader):活动信息(开始/结束时间 + 时区)/ 多语言
/// 内容(zh-CN · en 双语 tab:标题/描述/场地/地址按语言各一份——契约
/// translations 的场地/地址是逐语言字段,不合并为共享输入)/ 访问设置
/// (默认语言 + 开关组:创建仅 download/original,编辑四开关独立)。
/// 保存按钮固定在 bottomNavigationBar——键盘打开时常驻可达(§4.5)。
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。
library;
@@ -18,6 +19,8 @@ import '../features/albums/album_format.dart';
import '../features/albums/album_form.dart';
import '../features/albums/models.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/section_header.dart';
import '../ui/tokens.dart';
/// 创建([existing] 为 null)/ 编辑相册表单。
class AlbumFormPage extends ConsumerStatefulWidget {
@@ -220,7 +223,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// DefaultTabController 仅为 TabBar 提供 controller(内容区非
// TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。
return DefaultTabController(
@@ -228,116 +230,129 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
child: Scaffold(
appBar: AppBar(
title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit),
bottom: TabBar(
onTap: (index) => setState(() => _tabIndex = index),
tabs: [
Tab(text: l10n.localeChinese),
Tab(text: l10n.localeEnglish),
],
),
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: FilledButton(
onPressed: _canSubmit ? _submit : null,
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isCreate ? l10n.create : l10n.save),
),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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 Divider(height: 32),
Text(
l10n.albumDefaultLocale,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
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: 16),
_dateTimeRow(isStart: true),
_dateTimeRow(isStart: false),
const SizedBox(height: 8),
TextFormField(
controller: _timezone,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: l10n.albumTimezone,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
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) ...[
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),
..._errors.map(
(e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_errorText(l10n, e),
style: TextStyle(color: theme.colorScheme.error),
TextFormField(
controller: _timezone,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(labelText: l10n.albumTimezone),
),
const SizedBox(height: kSpace24),
// ── 分组 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),
// ── 分组 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),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _canSubmit ? _submit : null,
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isCreate ? l10n.create : l10n.save),
),
const SizedBox(height: 24),
],
),
),
),
),
@@ -361,7 +376,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: isDefault ? '${l10n.albumTitle} *' : l10n.albumTitle,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
@@ -369,28 +383,19 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
controller: description,
onChanged: (_) => setState(() {}),
maxLines: 2,
decoration: InputDecoration(
labelText: l10n.albumDescription,
border: const OutlineInputBorder(),
),
decoration: InputDecoration(labelText: l10n.albumDescription),
),
const SizedBox(height: 12),
TextFormField(
controller: venue,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: l10n.albumVenue,
border: const OutlineInputBorder(),
),
decoration: InputDecoration(labelText: l10n.albumVenue),
),
const SizedBox(height: 12),
TextFormField(
controller: address,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: l10n.albumAddress,
border: const OutlineInputBorder(),
),
decoration: InputDecoration(labelText: l10n.albumAddress),
),
],
);
@@ -412,7 +417,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
child: InputDecorator(
decoration: InputDecoration(
labelText: isStart ? l10n.albumStartAt : l10n.albumEndAtOptional,
border: const OutlineInputBorder(),
suffixIcon: value == null
? const Icon(Icons.edit_calendar_outlined)
: Row(