1f67b7c818
Co-Authored-By: Claude Code <noreply@anthropic.com>
465 lines
17 KiB
Dart
465 lines
17 KiB
Dart
/// 相册创建/编辑表单页(UI-01 §4.5 分组重构,P3 计划 Task 4 基础)。
|
||
///
|
||
/// 三分组(SectionHeader):活动信息(开始/结束时间 + 时区)/ 多语言
|
||
/// 内容(zh-CN · en 双语 tab:标题/描述/场地/地址按语言各一份——契约
|
||
/// translations 的场地/地址是逐语言字段,不合并为共享输入)/ 访问设置
|
||
/// (默认语言 + 开关组:创建仅 download/original,编辑四开关独立)。
|
||
/// 保存按钮固定在 bottomNavigationBar——键盘打开时常驻可达(§4.5)。
|
||
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
|
||
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。
|
||
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_l10n.dart';
|
||
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 {
|
||
const AlbumFormPage({this.existing, this.initialDraft, super.key});
|
||
|
||
/// 编辑目标;null = 创建模式。
|
||
final Album? existing;
|
||
|
||
/// 初始草稿(测试注入/预填);缺省从 [existing] 构建或全新(创建)。
|
||
final AlbumFormDraft? initialDraft;
|
||
|
||
@override
|
||
ConsumerState<AlbumFormPage> createState() => _AlbumFormPageState();
|
||
}
|
||
|
||
class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||
static const String _zh = 'zh-CN';
|
||
static const String _en = 'en';
|
||
|
||
late final List<TextEditingController> _controllers;
|
||
|
||
late final TextEditingController _zhTitle;
|
||
late final TextEditingController _zhDescription;
|
||
late final TextEditingController _zhVenue;
|
||
late final TextEditingController _zhAddress;
|
||
late final TextEditingController _enTitle;
|
||
late final TextEditingController _enDescription;
|
||
late final TextEditingController _enVenue;
|
||
late final TextEditingController _enAddress;
|
||
late final TextEditingController _timezone;
|
||
|
||
late String _defaultLocale;
|
||
DateTime? _startAt;
|
||
DateTime? _endAt;
|
||
late bool _uploadEnabled;
|
||
late bool _publicEnabled;
|
||
late bool _downloadEnabled;
|
||
late bool _originalDownloadEnabled;
|
||
|
||
int _tabIndex = 0;
|
||
List<AlbumFormError> _errors = const <AlbumFormError>[];
|
||
bool _submitting = false;
|
||
|
||
bool get _isCreate => widget.existing == null;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final initial = widget.existing == null
|
||
? (widget.initialDraft ??
|
||
AlbumFormDraft(
|
||
zh: const AlbumTranslationDraft(locale: _zh),
|
||
en: const AlbumTranslationDraft(locale: _en),
|
||
defaultLocale: _zh,
|
||
timezone: 'Asia/Shanghai',
|
||
))
|
||
: (widget.initialDraft ?? AlbumFormDraft.fromAlbum(widget.existing!));
|
||
_zhTitle = TextEditingController(text: initial.zh.title);
|
||
_zhDescription = TextEditingController(text: initial.zh.description);
|
||
_zhVenue = TextEditingController(text: initial.zh.venue);
|
||
_zhAddress = TextEditingController(text: initial.zh.address);
|
||
_enTitle = TextEditingController(text: initial.en.title);
|
||
_enDescription = TextEditingController(text: initial.en.description);
|
||
_enVenue = TextEditingController(text: initial.en.venue);
|
||
_enAddress = TextEditingController(text: initial.en.address);
|
||
_timezone = TextEditingController(text: initial.timezone);
|
||
_controllers = [
|
||
_zhTitle,
|
||
_zhDescription,
|
||
_zhVenue,
|
||
_zhAddress,
|
||
_enTitle,
|
||
_enDescription,
|
||
_enVenue,
|
||
_enAddress,
|
||
_timezone,
|
||
];
|
||
_defaultLocale = initial.defaultLocale;
|
||
_startAt = initial.startAt;
|
||
_endAt = initial.endAt;
|
||
_uploadEnabled = initial.uploadEnabled;
|
||
_publicEnabled = initial.publicEnabled;
|
||
_downloadEnabled = initial.downloadEnabled;
|
||
_originalDownloadEnabled = initial.originalDownloadEnabled;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
for (final c in _controllers) {
|
||
c.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
AlbumFormDraft _currentDraft() => AlbumFormDraft(
|
||
zh: AlbumTranslationDraft(
|
||
locale: _zh,
|
||
title: _zhTitle.text,
|
||
description: _zhDescription.text,
|
||
venue: _zhVenue.text,
|
||
address: _zhAddress.text,
|
||
),
|
||
en: AlbumTranslationDraft(
|
||
locale: _en,
|
||
title: _enTitle.text,
|
||
description: _enDescription.text,
|
||
venue: _enVenue.text,
|
||
address: _enAddress.text,
|
||
),
|
||
defaultLocale: _defaultLocale,
|
||
timezone: _timezone.text,
|
||
startAt: _startAt,
|
||
endAt: _endAt,
|
||
uploadEnabled: _uploadEnabled,
|
||
publicEnabled: _publicEnabled,
|
||
downloadEnabled: _downloadEnabled,
|
||
originalDownloadEnabled: _originalDownloadEnabled,
|
||
);
|
||
|
||
/// 编辑模式:与原值无任何差异时禁用保存(PATCH minProperties 1)。
|
||
bool get _canSubmit {
|
||
if (_submitting) return false;
|
||
if (_isCreate) return true;
|
||
return _currentDraft().toUpdatePatch(widget.existing!) != null;
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
final draft = _currentDraft();
|
||
final errors = draft.validate();
|
||
if (errors.isNotEmpty) {
|
||
setState(() => _errors = errors);
|
||
return;
|
||
}
|
||
setState(() {
|
||
_submitting = true;
|
||
_errors = const <AlbumFormError>[];
|
||
});
|
||
final l10n = AppLocalizations.of(context);
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
final navigator = Navigator.of(context);
|
||
try {
|
||
if (_isCreate) {
|
||
await ref.read(apiClientProvider).createAlbum(draft.toCreateRequest());
|
||
} else {
|
||
final patch = draft.toUpdatePatch(widget.existing!);
|
||
if (patch == null) {
|
||
navigator.pop(false);
|
||
return;
|
||
}
|
||
await ref
|
||
.read(apiClientProvider)
|
||
.updateAlbum(widget.existing!.id, patch);
|
||
}
|
||
navigator.pop(true);
|
||
} on AppException catch (error) {
|
||
messenger.showSnackBar(
|
||
SnackBar(content: Text(l10n.messageForError(error))),
|
||
);
|
||
} on Object {
|
||
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
||
} finally {
|
||
if (mounted) setState(() => _submitting = false);
|
||
}
|
||
}
|
||
|
||
/// 日期 + 时间选择器 → 相册时区墙钟 → UTC 瞬间。
|
||
Future<void> _pickDateTime({required bool isStart}) async {
|
||
final tzName = _timezone.text.trim();
|
||
final initial = (isStart ? _startAt : _endAt) ?? _startAt ?? DateTime.now();
|
||
final wall = tryToAlbumZone(initial, tzName) ?? initial.toUtc();
|
||
final pickedDate = await showDatePicker(
|
||
context: context,
|
||
initialDate: wall,
|
||
firstDate: DateTime(2000),
|
||
lastDate: DateTime(2100),
|
||
);
|
||
if (pickedDate == null || !mounted) return;
|
||
final pickedTime = await showTimePicker(
|
||
context: context,
|
||
initialTime: TimeOfDay(hour: wall.hour, minute: wall.minute),
|
||
);
|
||
if (pickedTime == null || !mounted) return;
|
||
final naive = DateTime(
|
||
pickedDate.year,
|
||
pickedDate.month,
|
||
pickedDate.day,
|
||
pickedTime.hour,
|
||
pickedTime.minute,
|
||
);
|
||
final utc = albumWallClockToUtc(naive, tzName);
|
||
setState(() {
|
||
if (isStart) {
|
||
_startAt = utc;
|
||
} else {
|
||
_endAt = utc;
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context);
|
||
// DefaultTabController 仅为 TabBar 提供 controller(内容区非
|
||
// TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。
|
||
return DefaultTabController(
|
||
length: 2,
|
||
child: Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit),
|
||
),
|
||
// 保存按钮固定底部:键盘打开时常驻可达(§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: 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),
|
||
|
||
// ── 分组 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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _translationFields({
|
||
required TextEditingController title,
|
||
required TextEditingController description,
|
||
required TextEditingController venue,
|
||
required TextEditingController address,
|
||
required bool isDefault,
|
||
}) {
|
||
final l10n = AppLocalizations.of(context);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
TextFormField(
|
||
controller: title,
|
||
onChanged: (_) => setState(() {}),
|
||
decoration: InputDecoration(
|
||
labelText: isDefault ? '${l10n.albumTitle} *' : l10n.albumTitle,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: description,
|
||
onChanged: (_) => setState(() {}),
|
||
maxLines: 2,
|
||
decoration: InputDecoration(labelText: l10n.albumDescription),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: venue,
|
||
onChanged: (_) => setState(() {}),
|
||
decoration: InputDecoration(labelText: l10n.albumVenue),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextFormField(
|
||
controller: address,
|
||
onChanged: (_) => setState(() {}),
|
||
decoration: InputDecoration(labelText: l10n.albumAddress),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 开始(必填)/ 结束(可选、可清除)时间行;展示按当前时区输入换算。
|
||
Widget _dateTimeRow({required bool isStart}) {
|
||
final l10n = AppLocalizations.of(context);
|
||
final theme = Theme.of(context);
|
||
final value = isStart ? _startAt : _endAt;
|
||
final formatted = value == null
|
||
? null
|
||
: formatAlbumDateTime(value, _timezone.text, l10n.localeName);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: InkWell(
|
||
onTap: () => _submitting ? null : _pickDateTime(isStart: isStart),
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: InputDecorator(
|
||
decoration: InputDecoration(
|
||
labelText: isStart ? l10n.albumStartAt : l10n.albumEndAtOptional,
|
||
suffixIcon: value == null
|
||
? const Icon(Icons.edit_calendar_outlined)
|
||
: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.edit_calendar_outlined),
|
||
if (!isStart)
|
||
IconButton(
|
||
tooltip: l10n.clearEndAt,
|
||
icon: const Icon(Icons.clear),
|
||
onPressed: _submitting
|
||
? null
|
||
: () => setState(() => _endAt = null),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
child: Text(
|
||
formatted ?? (isStart ? l10n.formStartAtRequired : l10n.notSet),
|
||
style: formatted == null
|
||
? theme.textTheme.bodyMedium?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
)
|
||
: theme.textTheme.bodyMedium,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _errorText(AppLocalizations l10n, AlbumFormError error) {
|
||
switch (error) {
|
||
case AlbumFormError.defaultLocaleMissing:
|
||
return l10n.formDefaultLocaleMissing;
|
||
case AlbumFormError.titleRequiredWithFields:
|
||
return l10n.formTitleRequiredWithFields;
|
||
case AlbumFormError.startAtRequired:
|
||
return l10n.formStartAtRequired;
|
||
case AlbumFormError.endAtNotAfterStartAt:
|
||
return l10n.formEndAtNotAfterStartAt;
|
||
case AlbumFormError.timezoneInvalid:
|
||
return l10n.albumTimezoneInvalid;
|
||
}
|
||
}
|
||
}
|