Files
photo-live/apps/mobile/lib/features/albums/album_form.dart
T

285 lines
9.3 KiB
Dart
Raw Normal View History

/// 相册表单草稿/校验/载荷构建(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();
}