fix(mobile): use correct timezone displayed in the info sheet (#24310)

* fixed the timezone issue in the Immich mobile app's metadata sheet to match the web app's behavior

* format dart

* now uses the shared applyTimezoneOffset() utility function from mobile/lib/utils/timezone.dart

* add tests

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Hai Sullivan
2025-12-03 03:37:19 +11:00
committed by GitHub
parent 116012f6f8
commit b11aecd184
5 changed files with 345 additions and 26 deletions
+4 -17
View File
@@ -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);
}
}
@@ -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,
+12 -3
View File
@@ -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(
+35
View File
@@ -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);
}