diff --git a/mobile/lib/extensions/datetime_extensions.dart b/mobile/lib/extensions/datetime_extensions.dart index 6a51d15df..d13f5e0a2 100644 --- a/mobile/lib/extensions/datetime_extensions.dart +++ b/mobile/lib/extensions/datetime_extensions.dart @@ -1,6 +1,10 @@ -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; +/// The active locale for date/time formatting, falling back to `en_US` for +/// supported locales that intl has no date-formatting data for. +String resolvedDateTimeLocale() => + Intl.verifiedLocale(Intl.defaultLocale, DateFormat.localeExists, onFailure: (_) => 'en_US')!; + extension TimeAgoExtension on DateTime { /// Displays the time difference of this [DateTime] object to the current time as a [String] String timeAgo({bool numericDates = true}) { @@ -39,7 +43,22 @@ extension TimeAgoExtension on DateTime { } } -/// Extension to format date ranges according to UI requirements +extension TimeFormatting on DateTime { + /// 24-hour `HH:mm` when [alwaysUse24HourFormat], otherwise the locale's 12-hour format. + String formatTime({required bool alwaysUse24HourFormat}) { + final locale = resolvedDateTimeLocale(); + return alwaysUse24HourFormat ? DateFormat.Hm(locale).format(this) : DateFormat.jm(locale).format(this); + } +} + +extension DateFormatting on DateTime { + /// Formats a single date, omitting the year when it is the current year. + String formatDate() { + final locale = resolvedDateTimeLocale(); + return year == DateTime.now().year ? DateFormat.MMMd(locale).format(this) : DateFormat.yMMMd(locale).format(this); + } +} + extension DateRangeFormatting on DateTime { /// Formats a date range according to specific rules: /// - Single date of this year: "Aug 28" @@ -47,41 +66,20 @@ extension DateRangeFormatting on DateTime { /// - Date range of this year: "Mar 23-May 31" /// - Date range of other year: "Aug 28 - Sep 30, 2023" /// - Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022" - static String formatDateRange(DateTime startDate, DateTime endDate, Locale? locale) { - final now = DateTime.now(); - final currentYear = now.year; - final localeString = locale?.toString() ?? 'en_US'; - - // Check if it's a single date (same day) + static String formatDateRange(DateTime startDate, DateTime endDate) { if (startDate.year == endDate.year && startDate.month == endDate.month && startDate.day == endDate.day) { - if (startDate.year == currentYear) { - // Single date of this year: "Aug 28" - return DateFormat.MMMd(localeString).format(startDate); - } else { - // Single date of other year: "Aug 28, 2023" - return DateFormat.yMMMd(localeString).format(startDate); - } + return startDate.formatDate(); } - // It's a date range + final locale = resolvedDateTimeLocale(); + if (startDate.year == endDate.year) { - // Same year - if (startDate.year == currentYear) { - // Date range of this year: "Mar 23-May 31" - final startFormatted = DateFormat.MMMd(localeString).format(startDate); - final endFormatted = DateFormat.MMMd(localeString).format(endDate); - return '$startFormatted - $endFormatted'; - } else { - // Date range of other year: "Aug 28 - Sep 30, 2023" - final startFormatted = DateFormat.MMMd(localeString).format(startDate); - final endFormatted = DateFormat.MMMd(localeString).format(endDate); - return '$startFormatted - $endFormatted, ${startDate.year}'; - } - } else { - // Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022" - final startFormatted = DateFormat.yMMMd(localeString).format(startDate); - final endFormatted = DateFormat.yMMMd(localeString).format(endDate); - return '$startFormatted - $endFormatted'; + final format = DateFormat.MMMd(locale); + final range = '${format.format(startDate)} - ${format.format(endDate)}'; + return startDate.year == DateTime.now().year ? range : '$range, ${startDate.year}'; } + + final format = DateFormat.yMMMd(locale); + return '${format.format(startDate)} - ${format.format(endDate)}'; } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index 9f426f6db..3b0984cfd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -45,18 +46,12 @@ class DateTimeDetails extends ConsumerWidget { } static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { - DateTime dateTime = asset.createdAt.toLocal(); - Duration timeZoneOffset = dateTime.timeZoneOffset; + final alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(ctx); - if (exifInfo?.dateTimeOriginal != null) { - (dateTime, timeZoneOffset) = applyTimezoneOffset( - dateTime: exifInfo!.dateTimeOriginal!, - timeZone: exifInfo.timeZone, - ); - } + final (dateTime, timeZoneOffset) = resolveAssetDateTime(asset, exifInfo); - final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); - final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + final date = DateFormat.yMMMEd(resolvedDateTimeLocale()).format(dateTime); + final time = dateTime.formatTime(alwaysUse24HourFormat: alwaysUse24HourFormat); final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; return '$date$_kSeparator$time $timezone'; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index deae9cbd0..30dc8ecfc 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/favorite.action.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; @@ -156,17 +156,13 @@ class _AssetInfoTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - DateTime dateTime = asset.createdAt.toLocal(); - final currentYear = DateTime.now().year; final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + final alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); - if (exifInfo?.dateTimeOriginal != null) { - (dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone); - } + final (dateTime, _) = resolveAssetDateTime(asset, exifInfo); - final isCurrentYear = dateTime.year == currentYear; - final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime); - final timeFormatted = DateFormat.jm().format(dateTime); + final dateFormatted = dateTime.formatDate(); + final timeFormatted = dateTime.formatTime(alwaysUse24HourFormat: alwaysUse24HourFormat); return Column( mainAxisSize: MainAxisSize.min, diff --git a/mobile/lib/utils/timezone.dart b/mobile/lib/utils/timezone.dart index 980d51de8..9db9c7c14 100644 --- a/mobile/lib/utils/timezone.dart +++ b/mobile/lib/utils/timezone.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:timezone/timezone.dart'; /// Applies timezone conversion to a DateTime using EXIF timezone information. @@ -35,3 +37,18 @@ import 'package:timezone/timezone.dart'; // If timezone is invalid, return UTC return (dt, dt.timeZoneOffset); } + +/// Resolves the effective local [DateTime] (and its timezone offset) for an asset. +/// +/// Prefers the EXIF `dateTimeOriginal` with its timezone when available, +/// otherwise falls back to the asset's [BaseAsset.createdAt] in local time. +/// +/// Returns a tuple of (resolved DateTime, timezone offset Duration). +(DateTime, Duration) resolveAssetDateTime(BaseAsset asset, ExifInfo? exifInfo) { + if (exifInfo?.dateTimeOriginal == null) { + final dateTime = asset.createdAt.toLocal(); + return (dateTime, dateTime.timeZoneOffset); + } + + return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone); +} diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 4b9532eb8..1a0a55a9b 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:ui'; import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -240,7 +239,6 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S DateRangeFormatting.formatDateRange( dateRange.value!.$1.toLocal(), dateRange.value!.$2.toLocal(), - context.locale, ), style: const TextStyle( color: Colors.white, diff --git a/mobile/test/modules/extensions/datetime_extensions_test.dart b/mobile/test/modules/extensions/datetime_extensions_test.dart index b1b1542fc..e0ada55a9 100644 --- a/mobile/test/modules/extensions/datetime_extensions_test.dart +++ b/mobile/test/modules/extensions/datetime_extensions_test.dart @@ -1,10 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; void main() { setUpAll(() async { await initializeDateFormatting(); + Intl.defaultLocale = 'en_US'; }); group('DateRangeFormatting.formatDateRange', () { @@ -12,34 +14,34 @@ void main() { test('returns single date format for this year', () { final date = DateTime(currentYear, 8, 28); // Aug 28 this year - final result = DateRangeFormatting.formatDateRange(date, date, null); + final result = DateRangeFormatting.formatDateRange(date, date); expect(result, 'Aug 28'); }); test('returns single date format for other year', () { final date = DateTime(2023, 8, 28); // Aug 28, 2023 - final result = DateRangeFormatting.formatDateRange(date, date, null); + final result = DateRangeFormatting.formatDateRange(date, date); expect(result, 'Aug 28, 2023'); }); test('returns date range format for this year', () { final startDate = DateTime(currentYear, 3, 23); // Mar 23 final endDate = DateTime(currentYear, 5, 31); // May 31 - final result = DateRangeFormatting.formatDateRange(startDate, endDate, null); + final result = DateRangeFormatting.formatDateRange(startDate, endDate); expect(result, 'Mar 23 - May 31'); }); test('returns date range format for other year (same year)', () { final startDate = DateTime(2023, 8, 28); // Aug 28 final endDate = DateTime(2023, 9, 30); // Sep 30 - final result = DateRangeFormatting.formatDateRange(startDate, endDate, null); + final result = DateRangeFormatting.formatDateRange(startDate, endDate); expect(result, 'Aug 28 - Sep 30, 2023'); }); test('returns date range format over multiple years', () { final startDate = DateTime(2021, 4, 17); // Apr 17, 2021 final endDate = DateTime(2022, 4, 9); // Apr 9, 2022 - final result = DateRangeFormatting.formatDateRange(startDate, endDate, null); + final result = DateRangeFormatting.formatDateRange(startDate, endDate); expect(result, 'Apr 17, 2021 - Apr 9, 2022'); }); });