/// 相册创建/编辑表单页(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 createState() => _AlbumFormPageState(); } class _AlbumFormPageState extends ConsumerState { static const String _zh = 'zh-CN'; static const String _en = 'en'; late final List _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 _errors = const []; 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 _submit() async { final draft = _currentDraft(); final errors = draft.validate(); if (errors.isNotEmpty) { setState(() => _errors = errors); return; } setState(() { _submitting = true; _errors = const []; }); 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 _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( 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; } } }