Files
photo-live/apps/mobile/lib/features/albums/album_format.dart
T
2026-09-21 14:55:29 +08:00

131 lines
4.5 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;UI-01 §4.1)。
///
/// 契约:startAt/endAt 按 UTC 存储,「展示按 timezone」。Dart 核心库无
/// IANA 时区数据库,引入 `timezone` 包(内嵌 tzdata)做墙钟换算,字符串
/// 格式化仍走 intl [DateFormat](App 语言)。tzdata 初始化为幂等懒加载
/// ([ensureTzInitialized]),生产与测试共用同一路径。
library;
import 'package:intl/intl.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
import 'models.dart';
bool _tzInitialized = false;
/// 初始化内嵌 tzdata(幂等;首次调用后为空操作)。
void ensureTzInitialized() {
if (!_tzInitialized) {
tzdata.initializeTimeZones();
_tzInitialized = true;
}
}
/// 解析 IANA 时区名;无效返回 null。
///
/// 内嵌 tzdata(10 年窗口)不含 `UTC` 顶名(仅 `Etc/UTC`),此处将
/// `UTC` 归一化到 `Etc/UTC`,避免最常用的时区名被误判非法。
tz.Location? _resolveLocation(String timeZone) {
ensureTzInitialized();
final name = timeZone.trim();
try {
return tz.getLocation(name == 'UTC' ? 'Etc/UTC' : name);
} on Object {
return null;
}
}
/// UTC 瞬间 → 相册时区墙钟;IANA 名无效返回 null。
tz.TZDateTime? tryToAlbumZone(DateTime instant, String timeZone) {
final location = _resolveLocation(timeZone);
if (location == null) return null;
return tz.TZDateTime.from(instant.toUtc(), location);
}
/// 时区名是否为合法 IANA 标识(表单校验用)。
bool isValidTimezone(String timeZone) =>
timeZone.trim().isNotEmpty &&
tryToAlbumZone(DateTime.utc(2026, 1, 1), timeZone) != null;
/// 按相册时区格式化时间(`y/M/d HH:mm` 模式,App locale)。
///
/// 时区无效时回退 UTC 展示(表单校验另行拦截无效时区,此处仅兜底)。
String formatAlbumDateTime(
DateTime utcInstant,
String timeZone,
String locale,
) {
final zoned = tryToAlbumZone(utcInstant, timeZone) ?? utcInstant.toUtc();
return DateFormat('y/M/d HH:mm', locale).format(zoned);
}
/// 相册墙钟(表单里选择的日期时间,设备本地时区标记的 naive 值)→
/// UTC 瞬间(提交与回显往返一致)。
///
/// 时区无效时按字面值视为 UTC(校验先行拦截,避免设备时区偏移污染)。
DateTime albumWallClockToUtc(DateTime wallClock, String timeZone) {
final location = _resolveLocation(timeZone);
if (location == null) {
return DateTime.utc(
wallClock.year,
wallClock.month,
wallClock.day,
wallClock.hour,
wallClock.minute,
);
}
return tz.TZDateTime(
location,
wallClock.year,
wallClock.month,
wallClock.day,
wallClock.hour,
wallClock.minute,
).toUtc();
}
// ── 多语言展示回退链(UI-01 §4.1)────────────────────────────────────────
//
// 展示顺序:界面语言(语言前缀匹配,App locale `zh`/`en` vs 契约
// `zh-CN`/`en`)→ 相册默认语言 → 首个条目 → 相册 ID 兜底。
/// 展示回退链:界面语言条目优先,其余按「默认语言在前」排列。
List<AlbumTranslation> albumDisplayTranslations(
AlbumSummary album,
String localeName,
) {
final lang = localeName.split('-').first.toLowerCase();
AlbumTranslation? langMatch;
for (final t in album.translations) {
final l = t.locale.toLowerCase();
if (l == localeName.toLowerCase() || l.split('-').first == lang) {
langMatch = t;
break;
}
}
int byDefault(AlbumTranslation a, AlbumTranslation b) =>
(a.locale == album.defaultLocale ? 0 : 1).compareTo(
b.locale == album.defaultLocale ? 0 : 1,
);
if (langMatch != null) {
final rest = album.translations.where((t) => t != langMatch).toList()
..sort(byDefault);
return [langMatch, ...rest];
}
return [...album.translations]..sort(byDefault);
}
/// 展示标题(UI-01 §4.1 回退链;无翻译时相册 ID 兜底)。
String albumDisplayTitle(AlbumSummary album, String localeName) =>
albumDisplayTranslations(album, localeName).firstOrNull?.title ?? album.id;
/// 展示场地(同一回退链上首个非空场地;全空返回 null,不占一整行空白)。
String? albumDisplayVenue(AlbumSummary album, String localeName) {
for (final t in albumDisplayTranslations(album, localeName)) {
final venue = t.venue?.trim();
if (venue != null && venue.isNotEmpty) return venue;
}
return null;
}