/// 相册创建/编辑表单页(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 放弃确认。 /// /// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段); /// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。 library; import 'dart:io' show File; 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 '../features/uploads/enqueue.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 with TickerProviderStateMixin { 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; /// 进入时的初始草稿快照(未保存变更判定基准)。 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 _errors = const []; bool _submitting = false; /// 用户选择的封面本机文件路径(null = 未选)。 /// 创建模式:相册创建成功后上传;编辑模式:保存后上传。 String? _coverFilePath; /// 用户选择的宣传图本机文件路径(null = 未选;上传时机同封面)。 String? _bannerFilePath; /// 编辑模式下的现有宣传图 URL(asset 现状查询,异步加载即时预览)。 String? _existingBannerUrl; /// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。 String? get _existingCoverUrl => _coverFilePath == null ? (widget.existing?.cover?.url) : null; bool get _isCreate => widget.existing == null; @override void initState() { super.initState(); // 编辑模式:加载现有宣传图预览(asset 现状查询;失败静默不阻塞表单)。 if (widget.existing != null) { Future.microtask(() async { try { final url = await ref .read(apiClientProvider) .fetchAlbumAssetUrl(widget.existing!.id, 'banner'); if (mounted) setState(() => _existingBannerUrl = url); } on Object { // 预览加载失败不阻塞表单 } }); } 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!)); _initial = initial; _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; _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(); } 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, // 原图下载受总下载开关约束:下载关闭时生效值恒为 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; if (_isCreate) return true; return _currentDraft().toUpdatePatch(widget.existing!) != null; } Future _submit() async { final draft = _currentDraft(); final errors = draft.validate(); if (errors.isNotEmpty) { // Details 字段校验失败:跳回 Details 页签展示错误(Access 页签无 // 可校验字段)。 setState(() { _errors = errors; if (_mainTabs != null && _mainIndex != 0) { _mainTabs!.index = 0; _mainIndex = 0; } }); return; } setState(() { _submitting = true; _errors = const []; }); final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); try { late final String albumId; if (_isCreate) { final album = await ref .read(apiClientProvider) .createAlbum(draft.toCreateRequest()); albumId = album.id; } else { final patch = draft.toUpdatePatch(widget.existing!); if (patch == null && _coverFilePath == null && _bannerFilePath == null) { navigator.pop(false); return; } if (patch != null) { await ref .read(apiClientProvider) .updateAlbum(widget.existing!.id, patch); } albumId = widget.existing!.id; } // 封面上传:相册保存成功后执行;失败不阻塞表单结果(封面可后补)。 if (_coverFilePath != null) { try { await ref .read(apiClientProvider) .uploadAlbumCoverFromFile(albumId, _coverFilePath!); } on Object { // 封面失败仅提示,不影响相册保存结果。 messenger.showSnackBar( SnackBar(content: Text(l10n.coverUploadFailed)), ); } } // 宣传图上传:同封面——保存成功后执行,失败不阻塞表单结果。 if (_bannerFilePath != null) { try { await ref .read(apiClientProvider) .uploadAlbumAssetFromFile( albumId, kind: 'banner', filePath: _bannerFilePath!, ); } on Object { messenger.showSnackBar( SnackBar(content: Text(l10n.bannerUploadFailed)), ); } } 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); } } /// 未保存变更 → 放弃确认;确认后直接 pop(绕过 PopScope 拦截)。 Future _confirmDiscard() async { final l10n = AppLocalizations.of(context); final discard = await showDialog( 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 _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; // UI06(用户需求 2026-09-23):只选日期不带时间——开始=当天 00:00, // 结束=当天 23:59(活动整天语义;契约仍为 datetime,仅交互粒度变化)。 final naive = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, isStart ? 0 : 23, isStart ? 0 : 59, ); final utc = albumWallClockToUtc(naive, tzName); setState(() { if (isStart) { _startAt = utc; } else { _endAt = utc; } }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); 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.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( kSpace16, kSpace8, kSpace16, kSpace12, ), child: FilledButton( onPressed: _canSubmit ? _submit : null, child: _submitting ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(_isCreate ? l10n.createAlbumSubmit : l10n.saveChanges), ), ), body: mainTabs == null ? _detailsForm(l10n) : _mainIndex == 0 ? _detailsForm(l10n) : _accessPanel(l10n), ), ); } /// 封面选择器:16:9 可点击区域。编辑模式显示现有封面预览。 Widget _buildCoverPicker(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.coverPicker, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: kSpace8), InkWell( onTap: _submitting ? null : _pickCoverImage, borderRadius: BorderRadius.circular(kRadiusInput), child: Container( height: 180, width: double.infinity, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(kRadiusInput), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 1, ), ), child: ClipRRect( borderRadius: BorderRadius.circular(kRadiusInput), child: _coverFilePath != null ? Image.file( File(_coverFilePath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => _coverPlaceholder(l10n), ) : _existingCoverUrl != null ? Image.network( _existingCoverUrl!, fit: BoxFit.cover, errorBuilder: (_, _, _) => _coverPlaceholder(l10n), ) : _coverPlaceholder(l10n), ), ), ), const SizedBox(height: kSpace4), Text( _coverFilePath != null ? l10n.coverSelected : l10n.coverPickerHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ); } Widget _coverPlaceholder(AppLocalizations l10n) => Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.add_photo_alternate_outlined, size: 48, color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: kSpace8), Text( l10n.coverPickerHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ); Future _pickCoverImage() async { final path = await ref.read(singleImagePickerProvider)(); if (path != null) { setState(() => _coverFilePath = path); } } Future _pickBannerImage() async { final path = await ref.read(singleImagePickerProvider)(); if (path != null) { setState(() => _bannerFilePath = path); } } /// 宣传图(banner)选择器:3:1 横幅可点击区域;编辑模式显示现有 banner。 Widget _buildBannerPicker(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.bannerPicker, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: kSpace8), InkWell( onTap: _submitting ? null : _pickBannerImage, borderRadius: BorderRadius.circular(kRadiusInput), child: Container( height: 120, width: double.infinity, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(kRadiusInput), border: Border.all( color: Theme.of(context).colorScheme.outlineVariant, width: 1, ), ), child: ClipRRect( borderRadius: BorderRadius.circular(kRadiusInput), child: _bannerFilePath != null ? Image.file( File(_bannerFilePath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => _bannerPlaceholder(l10n), ) : _existingBannerUrl != null ? Image.network( _existingBannerUrl!, fit: BoxFit.cover, errorBuilder: (_, _, _) => _bannerPlaceholder(l10n), ) : _bannerPlaceholder(l10n), ), ), ), const SizedBox(height: kSpace4), Text( _bannerFilePath != null ? l10n.bannerSelected : l10n.bannerPickerHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ); } Widget _bannerPlaceholder(AppLocalizations l10n) => Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.panorama_outlined, size: 40, color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: kSpace8), Text( l10n.bannerPickerHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ); /// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。 Widget _detailsForm(AppLocalizations l10n) { return Form( child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(kSpace16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 封面选择(16:9 可点击区域) ───────────────── _buildCoverPicker(l10n), const SizedBox(height: kSpace16), // ── 宣传图选择(3:1 横幅;编辑模式显示现有 banner)── _buildBannerPicker(l10n), const SizedBox(height: kSpace24), // ── 分组 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), // ── 分组 2:Description & languages ─────────────── SectionHeader(title: l10n.formDescriptionLanguages), const SizedBox(height: kSpace12), Text('${l10n.formDefaultLanguage} *'), 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: 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, 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; // 只显日期(选择粒度为日期;时间由开始/结束日的 00:00/23:59 语义承载)。 final wallShown = value == null ? null : (tryToAlbumZone(value, _timezone.text.trim()) ?? value.toUtc()); final formatted = wallShown == null ? null : '${wallShown.year}/${wallShown.month}/${wallShown.day}'; 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; } } }