2026-09-22 01:23:02 +08:00
|
|
|
|
/// 相册创建/编辑表单页(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 放弃确认。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
2026-09-22 13:55:37 +08:00
|
|
|
|
import 'dart:io' show File;
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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';
|
2026-09-22 13:55:37 +08:00
|
|
|
|
import '../features/uploads/enqueue.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
import '../l10n/app_localizations.dart';
|
2026-09-21 17:20:43 +08:00
|
|
|
|
import '../ui/components/section_header.dart';
|
|
|
|
|
|
import '../ui/tokens.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
/// 创建([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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
|
|
|
|
|
with TickerProviderStateMixin {
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 进入时的初始草稿快照(未保存变更判定基准)。
|
|
|
|
|
|
late final AlbumFormDraft _initial;
|
|
|
|
|
|
|
|
|
|
|
|
/// 语言 tab(Details 内 zh-CN/en 字段切换)。
|
|
|
|
|
|
late final TabController _langTabs;
|
|
|
|
|
|
int _langIndex = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/// Details/Access 主页签(仅编辑模式创建;创建模式无 Access)。
|
|
|
|
|
|
TabController? _mainTabs;
|
|
|
|
|
|
int _mainIndex = 0;
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
List<AlbumFormError> _errors = const <AlbumFormError>[];
|
|
|
|
|
|
bool _submitting = false;
|
|
|
|
|
|
|
2026-09-22 13:55:37 +08:00
|
|
|
|
/// 用户选择的封面本机文件路径(null = 未选)。
|
|
|
|
|
|
/// 创建模式:相册创建成功后上传;编辑模式:保存后上传。
|
|
|
|
|
|
String? _coverFilePath;
|
|
|
|
|
|
|
2026-09-23 11:07:54 +08:00
|
|
|
|
/// 用户选择的宣传图本机文件路径(null = 未选;上传时机同封面)。
|
|
|
|
|
|
String? _bannerFilePath;
|
|
|
|
|
|
|
|
|
|
|
|
/// 编辑模式下的现有宣传图 URL(asset 现状查询,异步加载即时预览)。
|
|
|
|
|
|
String? _existingBannerUrl;
|
|
|
|
|
|
|
2026-09-22 13:55:37 +08:00
|
|
|
|
/// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。
|
2026-09-23 11:07:54 +08:00
|
|
|
|
String? get _existingCoverUrl =>
|
|
|
|
|
|
_coverFilePath == null ? (widget.existing?.cover?.url) : null;
|
2026-09-22 13:55:37 +08:00
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
bool get _isCreate => widget.existing == null;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
2026-09-23 11:07:54 +08:00
|
|
|
|
// 编辑模式:加载现有宣传图预览(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 {
|
|
|
|
|
|
// 预览加载失败不阻塞表单
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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!));
|
2026-09-22 01:23:02 +08:00
|
|
|
|
_initial = initial;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
_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;
|
2026-09-22 01:23:02 +08:00
|
|
|
|
|
|
|
|
|
|
_langTabs = TabController(length: 2, vsync: this);
|
|
|
|
|
|
// 仅编辑模式有主页签;内容区按 _mainIndex 手动切换(非 TabBarView),
|
|
|
|
|
|
// 页签往返不重建字段——草稿共享、焦点不丢。
|
|
|
|
|
|
if (!_isCreate) {
|
|
|
|
|
|
_mainTabs = TabController(length: 2, vsync: this);
|
|
|
|
|
|
}
|
2026-09-19 01:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-09-22 01:23:02 +08:00
|
|
|
|
_langTabs.dispose();
|
|
|
|
|
|
_mainTabs?.dispose();
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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,
|
2026-09-22 01:23:02 +08:00
|
|
|
|
// 原图下载受总下载开关约束:下载关闭时生效值恒为 false。
|
|
|
|
|
|
originalDownloadEnabled: _downloadEnabled && _originalDownloadEnabled,
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 未保存变更判定:编辑 = 与原相册有差异(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
/// 编辑模式:与原值无任何差异时禁用保存(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) {
|
2026-09-22 01:23:02 +08:00
|
|
|
|
// Details 字段校验失败:跳回 Details 页签展示错误(Access 页签无
|
|
|
|
|
|
// 可校验字段)。
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_errors = errors;
|
|
|
|
|
|
if (_mainTabs != null && _mainIndex != 0) {
|
|
|
|
|
|
_mainTabs!.index = 0;
|
|
|
|
|
|
_mainIndex = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_submitting = true;
|
|
|
|
|
|
_errors = const <AlbumFormError>[];
|
|
|
|
|
|
});
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
|
|
|
|
final navigator = Navigator.of(context);
|
|
|
|
|
|
try {
|
2026-09-22 13:55:37 +08:00
|
|
|
|
late final String albumId;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
if (_isCreate) {
|
2026-09-22 13:55:37 +08:00
|
|
|
|
final album = await ref
|
|
|
|
|
|
.read(apiClientProvider)
|
|
|
|
|
|
.createAlbum(draft.toCreateRequest());
|
|
|
|
|
|
albumId = album.id;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
final patch = draft.toUpdatePatch(widget.existing!);
|
2026-09-23 11:07:54 +08:00
|
|
|
|
if (patch == null &&
|
|
|
|
|
|
_coverFilePath == null &&
|
|
|
|
|
|
_bannerFilePath == null) {
|
2026-09-19 01:56:38 +08:00
|
|
|
|
navigator.pop(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-22 13:55:37 +08:00
|
|
|
|
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)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-19 01:56:38 +08:00
|
|
|
|
}
|
2026-09-23 11:07:54 +08:00
|
|
|
|
// 宣传图上传:同封面——保存成功后执行,失败不阻塞表单结果。
|
|
|
|
|
|
if (_bannerFilePath != null) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ref
|
|
|
|
|
|
.read(apiClientProvider)
|
|
|
|
|
|
.uploadAlbumAssetFromFile(
|
|
|
|
|
|
albumId,
|
|
|
|
|
|
kind: 'banner',
|
|
|
|
|
|
filePath: _bannerFilePath!,
|
|
|
|
|
|
);
|
|
|
|
|
|
} on Object {
|
|
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
SnackBar(content: Text(l10n.bannerUploadFailed)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-19 01:56:38 +08:00
|
|
|
|
navigator.pop(true);
|
|
|
|
|
|
} on AppException catch (error) {
|
2026-09-22 01:23:02 +08:00
|
|
|
|
// 保存失败:保留输入,仅提示。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
SnackBar(content: Text(l10n.messageForError(error))),
|
|
|
|
|
|
);
|
|
|
|
|
|
} on Object {
|
|
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) setState(() => _submitting = false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 未保存变更 → 放弃确认;确认后直接 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
/// 日期 + 时间选择器 → 相册时区墙钟 → 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;
|
2026-09-23 14:13:58 +08:00
|
|
|
|
// UI06(用户需求 2026-09-23):只选日期不带时间——开始=当天 00:00,
|
|
|
|
|
|
// 结束=当天 23:59(活动整天语义;契约仍为 datetime,仅交互粒度变化)。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
final naive = DateTime(
|
|
|
|
|
|
pickedDate.year,
|
|
|
|
|
|
pickedDate.month,
|
|
|
|
|
|
pickedDate.day,
|
2026-09-23 14:13:58 +08:00
|
|
|
|
isStart ? 0 : 23,
|
|
|
|
|
|
isStart ? 0 : 59,
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
final utc = albumWallClockToUtc(naive, tzName);
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
if (isStart) {
|
|
|
|
|
|
_startAt = utc;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_endAt = utc;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
2026-09-22 01:23:02 +08:00
|
|
|
|
final mainTabs = _mainTabs;
|
|
|
|
|
|
return PopScope(
|
|
|
|
|
|
// 有未保存变更时拦截系统返回/aybePop;保存成功后的程序化 pop 不受
|
|
|
|
|
|
// 影响(Navigator.pop 绕过 canPop)。
|
|
|
|
|
|
canPop: !_isDirty,
|
|
|
|
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
|
|
|
|
if (didPop) return;
|
|
|
|
|
|
_confirmDiscard();
|
|
|
|
|
|
},
|
2026-09-19 01:56:38 +08:00
|
|
|
|
child: Scaffold(
|
|
|
|
|
|
appBar: AppBar(
|
2026-09-22 01:23:02 +08:00
|
|
|
|
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),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
|
|
|
|
|
|
bottomNavigationBar: SafeArea(
|
2026-09-23 11:07:54 +08:00
|
|
|
|
minimum: const EdgeInsets.fromLTRB(
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace8,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace12,
|
|
|
|
|
|
),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
child: FilledButton(
|
|
|
|
|
|
onPressed: _canSubmit ? _submit : null,
|
|
|
|
|
|
child: _submitting
|
|
|
|
|
|
? const SizedBox(
|
|
|
|
|
|
height: 20,
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
)
|
2026-09-22 01:23:02 +08:00
|
|
|
|
: Text(_isCreate ? l10n.createAlbumSubmit : l10n.saveChanges),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
body: mainTabs == null
|
|
|
|
|
|
? _detailsForm(l10n)
|
|
|
|
|
|
: _mainIndex == 0
|
2026-09-23 11:07:54 +08:00
|
|
|
|
? _detailsForm(l10n)
|
|
|
|
|
|
: _accessPanel(l10n),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-21 17:20:43 +08:00
|
|
|
|
|
2026-09-22 13:55:37 +08:00
|
|
|
|
/// 封面选择器:16:9 可点击区域。编辑模式显示现有封面预览。
|
|
|
|
|
|
Widget _buildCoverPicker(AppLocalizations l10n) {
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
2026-09-23 11:07:54 +08:00
|
|
|
|
Text(l10n.coverPicker, style: Theme.of(context).textTheme.titleMedium),
|
2026-09-22 13:55:37 +08:00
|
|
|
|
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
|
2026-09-23 11:07:54 +08:00
|
|
|
|
? Image.network(
|
|
|
|
|
|
_existingCoverUrl!,
|
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
|
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
|
|
|
|
|
)
|
|
|
|
|
|
: _coverPlaceholder(l10n),
|
2026-09-22 13:55:37 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace4),
|
|
|
|
|
|
Text(
|
2026-09-23 11:07:54 +08:00
|
|
|
|
_coverFilePath != null ? l10n.coverSelected : l10n.coverPickerHint,
|
2026-09-22 13:55:37 +08:00
|
|
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
2026-09-23 11:07:54 +08:00
|
|
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
|
|
|
|
),
|
2026-09-22 13:55:37 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _coverPlaceholder(AppLocalizations l10n) => Column(
|
2026-09-23 11:07:54 +08:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
2026-09-22 13:55:37 +08:00
|
|
|
|
|
|
|
|
|
|
Future<void> _pickCoverImage() async {
|
|
|
|
|
|
final path = await ref.read(singleImagePickerProvider)();
|
|
|
|
|
|
if (path != null) {
|
|
|
|
|
|
setState(() => _coverFilePath = path);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 11:07:54 +08:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-22 13:55:37 +08:00
|
|
|
|
/// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。
|
2026-09-22 01:23:02 +08:00
|
|
|
|
Widget _detailsForm(AppLocalizations l10n) {
|
|
|
|
|
|
return Form(
|
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(kSpace16),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
2026-09-22 13:55:37 +08:00
|
|
|
|
// ── 封面选择(16:9 可点击区域) ─────────────────
|
|
|
|
|
|
_buildCoverPicker(l10n),
|
2026-09-23 11:07:54 +08:00
|
|
|
|
const SizedBox(height: kSpace16),
|
|
|
|
|
|
|
|
|
|
|
|
// ── 宣传图选择(3:1 横幅;编辑模式显示现有 banner)──
|
|
|
|
|
|
_buildBannerPicker(l10n),
|
2026-09-22 13:55:37 +08:00
|
|
|
|
const SizedBox(height: kSpace24),
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
// ── 分组 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),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
// ── 分组 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),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
],
|
2026-09-21 17:20:43 +08:00
|
|
|
|
),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
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),
|
|
|
|
|
|
],
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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,
|
2026-09-21 17:20:43 +08:00
|
|
|
|
decoration: InputDecoration(labelText: l10n.albumDescription),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
TextFormField(
|
|
|
|
|
|
controller: venue,
|
|
|
|
|
|
onChanged: (_) => setState(() {}),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
decoration: InputDecoration(labelText: l10n.albumVenue),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
TextFormField(
|
|
|
|
|
|
controller: address,
|
|
|
|
|
|
onChanged: (_) => setState(() {}),
|
2026-09-21 17:20:43 +08:00
|
|
|
|
decoration: InputDecoration(labelText: l10n.albumAddress),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 开始(必填)/ 结束(可选、可清除)时间行;展示按当前时区输入换算。
|
|
|
|
|
|
Widget _dateTimeRow({required bool isStart}) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
|
final value = isStart ? _startAt : _endAt;
|
2026-09-23 14:13:58 +08:00
|
|
|
|
// 只显日期(选择粒度为日期;时间由开始/结束日的 00:00/23:59 语义承载)。
|
|
|
|
|
|
final wallShown = value == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: (tryToAlbumZone(value, _timezone.text.trim()) ?? value.toUtc());
|
|
|
|
|
|
final formatted = wallShown == null
|
2026-09-19 01:56:38 +08:00
|
|
|
|
? null
|
2026-09-23 14:13:58 +08:00
|
|
|
|
: '${wallShown.year}/${wallShown.month}/${wallShown.day}';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|