c94b9e5e58
Co-Authored-By: Claude Code <noreply@anthropic.com>
131 lines
4.5 KiB
Dart
131 lines
4.5 KiB
Dart
/// 相册时区感知的时间展示与多语言展示回退(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;
|
||
}
|