Files
photo-live/apps/mobile/lib/pages/album_form_page.dart
T
浅念 e44b35db28 feat(albums): date-only pickers for start/end (create & edit, admin + app)
- 管理后台:DatePicker type=datetime → date;提交归一(开始=当天 00:00、
  结束=当天 23:59 整天活动语义;契约仍 datetime 仅交互粒度)
- App:去掉时间选择步骤(选日期即定;同上归一);表单显示只显 yyyy/M/d
- e2e 日期填入改 '2026-10-01'(date 格式);widget 断言日期粒度
- admin 87/87 + build、app 397/397 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 14:13:58 +08:00

824 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册创建/编辑表单页(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<AlbumFormPage> createState() => _AlbumFormPageState();
}
class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
with TickerProviderStateMixin {
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;
/// 进入时的初始草稿快照(未保存变更判定基准)。
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;
/// 用户选择的封面本机文件路径(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<void>.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<void> _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 <AlbumFormError>[];
});
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<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();
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<void> _pickCoverImage() async {
final path = await ref.read(singleImagePickerProvider)();
if (path != null) {
setState(() => _coverFilePath = path);
}
}
Future<void> _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<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,
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;
}
}
}