Files
photo-live/apps/mobile/lib/features/albums/album_form.dart
T
2026-09-19 01:56:38 +08:00

285 lines
9.3 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.
/// 相册表单草稿/校验/载荷构建(P3 计划 Task 4)。
///
/// 镜像 admin Web `albumValidation.ts` 的客户端预检(服务端
/// VALIDATION_FAILED 仍是权威):
/// - 默认语言条目必须存在且 title 非空(契约 translations minItems 1 +
/// defaultLocale 覆盖);
/// - 「某语言填了描述/场地/地址但 title 空」视为漏填;
/// - endAt 必须严格晚于 startAt(null=未定放行);
/// - 时区必须是非空合法 IANA 名。
///
/// PATCH 载荷最小化:[toUpdatePatch] 仅提交与原相册不同的字段——四个
/// 开关的「独立性」由此保证(未拨动的开关不出现在请求体),translations
/// 全量替换(有任一语言内容变更即整组提交)。
library;
import 'album_format.dart';
import 'models.dart';
/// 表单错误码(展示层映射 l10n 文案)。
enum AlbumFormError {
/// 默认语言条目缺失或标题为空。
defaultLocaleMissing,
/// 某语言填了其他字段但没填标题。
titleRequiredWithFields,
/// 未选择开始时间。
startAtRequired,
/// 结束时间不晚于开始时间。
endAtNotAfterStartAt,
/// 时区为空或不是合法 IANA 名。
timezoneInvalid,
}
/// 单语言表单草稿(zh-CN / en 两个 tab 各一份;空串 = 未填)。
class AlbumTranslationDraft {
const AlbumTranslationDraft({
required this.locale,
this.title = '',
this.description = '',
this.venue = '',
this.address = '',
});
/// 从服务端条目预填(null 可选字段归一为空串)。
factory AlbumTranslationDraft.fromTranslation(AlbumTranslation t) =>
AlbumTranslationDraft(
locale: t.locale,
title: t.title,
description: t.description ?? '',
venue: t.venue ?? '',
address: t.address ?? '',
);
/// 语言(zh-CN | en)。
final String locale;
/// 标题(默认语言必填)。
final String title;
/// 描述;空串 = 未填。
final String description;
/// 场地名称;空串 = 未填。
final String venue;
/// 场地地址;空串 = 未填。
final String address;
/// 是否填了除标题以外的任一字段。
bool get hasOtherFields =>
description.trim().isNotEmpty ||
venue.trim().isNotEmpty ||
address.trim().isNotEmpty;
}
/// 相册表单草稿(创建/编辑共用;UI 层持可变控件,提交前汇成本类走纯逻辑)。
class AlbumFormDraft {
const AlbumFormDraft({
required this.zh,
required this.en,
required this.defaultLocale,
required this.timezone,
this.startAt,
this.endAt,
this.uploadEnabled = true,
this.publicEnabled = true,
this.downloadEnabled = true,
this.originalDownloadEnabled = false,
});
/// 从既有相册预填(编辑模式)。
factory AlbumFormDraft.fromAlbum(Album album) => AlbumFormDraft(
zh: _draftFor(album, 'zh-CN'),
en: _draftFor(album, 'en'),
defaultLocale: album.defaultLocale,
timezone: album.timezone,
startAt: album.startAt,
endAt: album.endAt,
uploadEnabled: album.uploadEnabled,
publicEnabled: album.publicEnabled,
downloadEnabled: album.downloadEnabled,
originalDownloadEnabled: album.originalDownloadEnabled,
);
static AlbumTranslationDraft _draftFor(Album album, String locale) {
for (final t in album.translations) {
if (t.locale == locale) return AlbumTranslationDraft.fromTranslation(t);
}
return AlbumTranslationDraft(locale: locale);
}
/// 中文条目草稿。
final AlbumTranslationDraft zh;
/// 英文条目草稿。
final AlbumTranslationDraft en;
/// 默认语言(zh-CN | en)。
final String defaultLocale;
/// IANA 时区(表单缺省 Asia/Shanghai)。
final String timezone;
/// 开始时间(UTC 瞬间);null = 未选。
final DateTime? startAt;
/// 结束时间(UTC 瞬间);null = 未定。
final DateTime? endAt;
/// 是否允许新上传任务(仅编辑可改)。
final bool uploadEnabled;
/// 是否公开可见(仅编辑可改)。
final bool publicEnabled;
/// 允许下载浏览版本。
final bool downloadEnabled;
/// 允许下载原图。
final bool originalDownloadEnabled;
/// 两个语言的草稿(zh-CN 在前,与 tab 顺序一致)。
List<AlbumTranslationDraft> get drafts => <AlbumTranslationDraft>[zh, en];
/// 客户端预检;返回按序去重的错误码(空列表 = 通过)。
List<AlbumFormError> validate() {
final errors = <AlbumFormError>{};
final defaultDraft = drafts
.where((d) => d.locale == defaultLocale)
.toList();
if (defaultDraft.isEmpty || defaultDraft.first.title.trim().isEmpty) {
errors.add(AlbumFormError.defaultLocaleMissing);
}
if (startAt == null) {
errors.add(AlbumFormError.startAtRequired);
}
for (final d in drafts) {
if (d.title.trim().isEmpty && d.hasOtherFields) {
errors.add(AlbumFormError.titleRequiredWithFields);
}
}
if (endAt != null &&
startAt != null &&
!endAt!.isAfter(startAt!)) {
errors.add(AlbumFormError.endAtNotAfterStartAt);
}
if (!isValidTimezone(timezone)) {
errors.add(AlbumFormError.timezoneInvalid);
}
return errors.toList();
}
/// 提炼 translations 载荷:只保留标题非空的语言条目,空白字段归一
/// null(镜像 admin toTranslationPayload,避免空串占位覆盖 null 语义)。
List<AlbumTranslation> toTranslationPayload() => drafts
.where((d) => d.title.trim().isNotEmpty)
.map(
(d) => AlbumTranslation(
locale: d.locale,
title: d.title.trim(),
description: _orNull(d.description),
venue: _orNull(d.venue),
address: _orNull(d.address),
),
)
.toList();
/// 创建请求体(调用前须已通过 [validate],startAt 非空)。
///
/// 契约缺省 uploadEnabled/publicEnabled=true 不可在创建时覆盖,
/// 请求体不含这两个开关([AlbumCreateRequest] 亦无对应字段)。
AlbumCreateRequest toCreateRequest() => AlbumCreateRequest(
translations: toTranslationPayload(),
defaultLocale: defaultLocale,
startAt: startAt!,
endAt: endAt,
timezone: timezone.trim(),
downloadEnabled: downloadEnabled,
originalDownloadEnabled: originalDownloadEnabled,
);
/// 编辑载荷(最小 PATCH):仅提交与 [original] 不同的字段。
///
/// 无任何变更时返回 null(调用方据此免提交/禁用保存)。
AlbumUpdateRequest? toUpdatePatch(Album original) {
final tzName = timezone.trim();
final translations = toTranslationPayload();
final translationsChanged =
!_sameTranslations(translations, original.translations);
final localeChanged = defaultLocale != original.defaultLocale;
final startChanged =
startAt != null && !_sameInstant(startAt!, original.startAt);
final endCleared = endAt == null && original.endAt != null;
final endChanged = endAt != null &&
(original.endAt == null || !_sameInstant(endAt!, original.endAt!));
final tzChanged = tzName != original.timezone;
final uploadChanged = uploadEnabled != original.uploadEnabled;
final publicChanged = publicEnabled != original.publicEnabled;
final downloadChanged = downloadEnabled != original.downloadEnabled;
final originalChanged =
originalDownloadEnabled != original.originalDownloadEnabled;
final touched =
translationsChanged ||
localeChanged ||
startChanged ||
endCleared ||
endChanged ||
tzChanged ||
uploadChanged ||
publicChanged ||
downloadChanged ||
originalChanged;
if (!touched) return null;
return AlbumUpdateRequest(
translations: translationsChanged ? translations : null,
defaultLocale: localeChanged ? defaultLocale : null,
startAt: startChanged ? startAt : null,
endAt: endChanged ? endAt : null,
clearEndAt: endCleared,
timezone: tzChanged ? tzName : null,
uploadEnabled: uploadChanged ? uploadEnabled : null,
publicEnabled: publicChanged ? publicEnabled : null,
downloadEnabled: downloadChanged ? downloadEnabled : null,
originalDownloadEnabled: originalChanged ? originalDownloadEnabled : null,
);
}
static String? _orNull(String value) =>
value.trim().isNotEmpty ? value.trim() : null;
/// 瞬间相等(DateTime.== 额外比较 isUtc 标记,此处只比时间轴)。
static bool _sameInstant(DateTime a, DateTime b) =>
a.microsecondsSinceEpoch == b.microsecondsSinceEpoch;
/// translations 组是否等价(按 locale 对齐,字段 trim 后比较;
/// null 与空串视为相等)。
static bool _sameTranslations(
List<AlbumTranslation> a,
List<AlbumTranslation> b,
) {
if (a.length != b.length) return false;
final byLocale = <String, AlbumTranslation>{
for (final t in b) t.locale: t,
};
for (final t in a) {
final o = byLocale[t.locale];
if (o == null) return false;
if (!_eq(t.title, o.title) ||
!_eq(t.description, o.description) ||
!_eq(t.venue, o.venue) ||
!_eq(t.address, o.address)) {
return false;
}
}
return true;
}
static bool _eq(String? a, String? b) => a?.trim() == b?.trim();
}