diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart index 22d5d5030a..a8ca7ef2aa 100644 --- a/mobile/lib/extensions/asset_extensions.dart +++ b/mobile/lib/extensions/asset_extensions.dart @@ -1,5 +1,5 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:timezone/timezone.dart'; +import 'package:immich_mobile/utils/timezone.dart'; extension TZExtension on Asset { /// Returns the created time of the asset from the exif info (if available) or from @@ -7,24 +7,11 @@ extension TZExtension on Asset { /// the timezone offset in [Duration] (DateTime, Duration) getTZAdjustedTimeAndOffset() { DateTime dt = fileCreatedAt.toLocal(); + if (exifInfo?.dateTimeOriginal != null) { - dt = exifInfo!.dateTimeOriginal!; - if (exifInfo?.timeZone != null) { - dt = dt.toUtc(); - try { - final location = getLocation(exifInfo!.timeZone!); - dt = TZDateTime.from(dt, location); - } on LocationNotFoundException { - RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); - final m = re.firstMatch(exifInfo!.timeZone!); - if (m != null) { - final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); - dt = dt.add(duration); - return (dt, duration); - } - } - } + return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone); } + return (dt, dt.timeZoneOffset); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 582a33136a..276034d3d6 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; @@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' • '; @@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget { class _AssetDetailBottomSheet extends ConsumerWidget { const _AssetDetailBottomSheet(); - String _getDateTime(BuildContext ctx, BaseAsset asset) { - final dateTime = asset.createdAt.toLocal(); + String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + // Use EXIF timezone information if available (matching web app behavior) + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = dateTime.timeZoneOffset.isNegative - ? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}' - : 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'; + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; return '$date$_kSeparator$time $timezone'; } @@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { children: [ // Asset Date and Time SheetTile( - title: _getDateTime(context, asset), + title: _getDateTime(context, asset, exifInfo), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 59b627ecc3..4261613a19 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; @@ -175,9 +176,17 @@ class ActionService { } final exifData = await _remoteAssetRepository.getExif(assetId); - initialDate = asset.createdAt.toLocal(); - offset = initialDate.timeZoneOffset; - timeZone = exifData?.timeZone; + + // Use EXIF timezone information if available (matching web app and display behavior) + DateTime dt = asset.createdAt.toLocal(); + offset = dt.timeZoneOffset; + + if (exifData?.dateTimeOriginal != null) { + timeZone = exifData!.timeZone; + (dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone); + } + + initialDate = dt; } final dateTime = await showDateTimePicker( diff --git a/mobile/lib/utils/timezone.dart b/mobile/lib/utils/timezone.dart new file mode 100644 index 0000000000..d75122062f --- /dev/null +++ b/mobile/lib/utils/timezone.dart @@ -0,0 +1,35 @@ +import 'package:timezone/timezone.dart'; + +/// Applies timezone conversion to a DateTime using EXIF timezone information. +/// +/// This function handles two timezone formats: +/// 1. Named timezone locations (e.g., "Asia/Hong_Kong") +/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00") +/// +/// Returns a tuple of (adjusted DateTime, timezone offset Duration) +(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) { + DateTime dt = dateTime.toUtc(); + + if (timeZone == null) { + return (dt, dt.timeZoneOffset); + } + + try { + // Try to get timezone location from database + final location = getLocation(timeZone); + dt = TZDateTime.from(dt, location); + return (dt, dt.timeZoneOffset); + } on LocationNotFoundException { + // Handle UTC offset format (e.g., "UTC+08:00") + RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); + final m = re.firstMatch(timeZone); + if (m != null) { + final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); + dt = dt.add(duration); + return (dt, duration); + } + } + + // If timezone is invalid, return UTC + return (dt, dt.timeZoneOffset); +} diff --git a/mobile/test/utils/timezone_test.dart b/mobile/test/utils/timezone_test.dart new file mode 100644 index 0000000000..d1e89dc473 --- /dev/null +++ b/mobile/test/utils/timezone_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:timezone/data/latest.dart' as tz; + +void main() { + setUpAll(() { + tz.initializeTimeZones(); + }); + + group('applyTimezoneOffset', () { + group('with named timezone locations', () { + test('should convert UTC to Asia/Hong_Kong (+08:00)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Hong_Kong', + ); + + expect(adjustedTime.hour, 20); // 12:00 UTC + 8 hours = 20:00 + expect(offset, const Duration(hours: 8)); + }); + + test('should convert UTC to America/New_York (handles DST)', () { + // Summer time (EDT = UTC-4) + final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0); + final (summerTime, summerOffset) = applyTimezoneOffset( + dateTime: summerUtc, + timeZone: 'America/New_York', + ); + + expect(summerTime.hour, 8); // 12:00 UTC - 4 hours = 08:00 + expect(summerOffset, const Duration(hours: -4)); + + // Winter time (EST = UTC-5) + final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0); + final (winterTime, winterOffset) = applyTimezoneOffset( + dateTime: winterUtc, + timeZone: 'America/New_York', + ); + + expect(winterTime.hour, 7); // 12:00 UTC - 5 hours = 07:00 + expect(winterOffset, const Duration(hours: -5)); + }); + + test('should convert UTC to Europe/London', () { + // Winter (GMT = UTC+0) + final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0); + final (winterTime, winterOffset) = applyTimezoneOffset( + dateTime: winterUtc, + timeZone: 'Europe/London', + ); + + expect(winterTime.hour, 12); + expect(winterOffset, Duration.zero); + + // Summer (BST = UTC+1) + final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0); + final (summerTime, summerOffset) = applyTimezoneOffset( + dateTime: summerUtc, + timeZone: 'Europe/London', + ); + + expect(summerTime.hour, 13); + expect(summerOffset, const Duration(hours: 1)); + }); + + test('should handle timezone with 30-minute offset (Asia/Kolkata)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Kolkata', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 30); // 12:00 UTC + 5:30 = 17:30 + expect(offset, const Duration(hours: 5, minutes: 30)); + }); + + test('should handle timezone with 45-minute offset (Asia/Kathmandu)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Kathmandu', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 45); // 12:00 UTC + 5:45 = 17:45 + expect(offset, const Duration(hours: 5, minutes: 45)); + }); + }); + + group('with UTC offset format', () { + test('should handle UTC+08:00 format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+08:00', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC-05:00 format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-05:00', + ); + + expect(adjustedTime.hour, 7); + expect(offset, const Duration(hours: -5)); + }); + + test('should handle UTC+8 format (without minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+8', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC-5 format (without minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-5', + ); + + expect(adjustedTime.hour, 7); + expect(offset, const Duration(hours: -5)); + }); + + test('should handle plain UTC format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC', + ); + + expect(adjustedTime.hour, 12); + expect(offset, Duration.zero); + }); + + test('should handle lowercase utc format', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'utc+08:00', + ); + + expect(adjustedTime.hour, 20); + expect(offset, const Duration(hours: 8)); + }); + + test('should handle UTC+05:30 format (with minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC+05:30', + ); + + expect(adjustedTime.hour, 17); + expect(adjustedTime.minute, 30); + expect(offset, const Duration(hours: 5, minutes: 30)); + }); + }); + + group('with null or invalid timezone', () { + test('should return UTC time when timezone is null', () { + final localTime = DateTime(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: localTime, + timeZone: null, + ); + + expect(adjustedTime.isUtc, true); + expect(offset, adjustedTime.timeZoneOffset); + }); + + test('should return UTC time when timezone is invalid', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Invalid/Timezone', + ); + + expect(adjustedTime.isUtc, true); + expect(adjustedTime.hour, 12); + expect(offset, adjustedTime.timeZoneOffset); + }); + + test('should return UTC time when UTC offset format is malformed', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC++08', + ); + + expect(adjustedTime.isUtc, true); + expect(adjustedTime.hour, 12); + }); + }); + + group('edge cases', () { + test('should handle date crossing midnight forward', () { + final utcTime = DateTime.utc(2024, 6, 15, 20, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'Asia/Tokyo', // UTC+9 + ); + + expect(adjustedTime.day, 16); // Crosses to next day + expect(adjustedTime.hour, 5); // 20:00 UTC + 9 = 05:00 next day + expect(offset, const Duration(hours: 9)); + }); + + test('should handle date crossing midnight backward', () { + final utcTime = DateTime.utc(2024, 6, 15, 3, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'America/Los_Angeles', // UTC-7 in summer + ); + + expect(adjustedTime.day, 14); // Crosses to previous day + expect(adjustedTime.hour, 20); // 03:00 UTC - 7 = 20:00 previous day + expect(offset, const Duration(hours: -7)); + }); + + test('should handle year boundary crossing', () { + final utcTime = DateTime.utc(2024, 1, 1, 2, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'America/New_York', // UTC-5 in winter + ); + + expect(adjustedTime.year, 2023); + expect(adjustedTime.month, 12); + expect(adjustedTime.day, 31); + expect(adjustedTime.hour, 21); // 02:00 UTC - 5 = 21:00 Dec 31 + }); + + test('should convert local time to UTC before applying timezone', () { + // Create a local time (not UTC) + final localTime = DateTime(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, _) = applyTimezoneOffset( + dateTime: localTime, + timeZone: 'Asia/Hong_Kong', + ); + + // The function converts to UTC first, then applies timezone + // So local 12:00 -> UTC (depends on local timezone) -> HK time + // We can verify it's working by checking it's a TZDateTime + expect(adjustedTime, isNotNull); + }); + }); + }); +}