diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 2e4a239a0b..f48d88d764 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -194,12 +194,15 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } - Future updateDateTime(List ids, DateTime dateTime) { + Future updateDateTime(List ids, DateTime dateTime, {String? timeZone}) { return _db.batch((batch) async { for (final id in ids) { batch.update( _db.remoteExifEntity, - RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)), + RemoteExifEntityCompanion( + dateTimeOriginal: Value(dateTime), + timeZone: timeZone == null ? const Value.absent() : Value(timeZone), + ), where: (e) => e.assetId.equals(id), ); batch.update( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 0fdfb7e21b..40233e90c4 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -59,10 +59,8 @@ class AssetApiRepository extends ApiRepository { ); } - Future updateDateTime(List ids, DateTime dateTime) async { - return _api.updateAssets( - AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime.toIso8601String())), - ); + Future updateDateTime(List ids, String dateTime) async { + return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: Optional.present(dateTime))); } Future stack(List ids) async { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index d77ec44492..8e01777c5d 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -206,15 +206,24 @@ class ActionService { return false; } - // convert dateTime to DateTime object - final parsedDateTime = DateTime.parse(dateTime); - - await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime); - await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime); + await applyDateTime(remoteIds, dateTime); return true; } + @visibleForTesting + Future applyDateTime(List remoteIds, String dateTime) async { + final parsedDateTime = DateTime.parse(dateTime); + final offset = RegExp(r'[+-]\d{2}:\d{2}$').firstMatch(dateTime)?.group(0); + + await _assetApiRepository.updateDateTime(remoteIds, dateTime); + await _remoteAssetRepository.updateDateTime( + remoteIds, + parsedDateTime, + timeZone: offset == null ? null : 'UTC$offset', + ); + } + Future removeFromAlbum(List remoteIds, String albumId) async { final result = await _albumApiRepository.removeAssets(albumId, remoteIds); if (result.removed.isNotEmpty) { diff --git a/mobile/lib/utils/timezone.dart b/mobile/lib/utils/timezone.dart index d75122062f..3e8c42d1b2 100644 --- a/mobile/lib/utils/timezone.dart +++ b/mobile/lib/utils/timezone.dart @@ -24,7 +24,9 @@ import 'package:timezone/timezone.dart'; 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')); + final hours = int.parse(m.group(1) ?? '0'); + final minutes = int.parse(m.group(2) ?? '0'); + final duration = Duration(hours: hours, minutes: hours.isNegative ? -minutes : minutes); dt = dt.add(duration); return (dt, duration); } diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index a25ee9920d..ef5ece38e9 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -99,6 +99,49 @@ void main() { }); }); + group('ActionService.applyDateTime', () { + const ids = ['asset_id_1']; + + test('sends the picked value to the api with its offset intact', () async { + const picked = '2026-06-10T19:15:00.000+06:00'; + when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {}); + when( + () => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00'), + ).thenAnswer((_) async {}); + + await sut.applyDateTime(ids, picked); + + verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1); + verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC+06:00')).called(1); + }); + + test('handles negative offsets', () async { + const picked = '2026-01-05T08:00:00.000-05:30'; + when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {}); + when( + () => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30'), + ).thenAnswer((_) async {}); + + await sut.applyDateTime(ids, picked); + + verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1); + verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: 'UTC-05:30')).called(1); + }); + + test('writes no timezone when the value has no offset', () async { + const picked = '2026-06-10T13:15:00.000Z'; + when(() => assetApiRepository.updateDateTime(ids, picked)).thenAnswer((_) async {}); + when( + () => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null), + ).thenAnswer((_) async {}); + + await sut.applyDateTime(ids, picked); + + verify(() => assetApiRepository.updateDateTime(ids, picked)).called(1); + verify(() => remoteAssetRepository.updateDateTime(ids, DateTime.parse(picked), timeZone: null)).called(1); + }); + }); + group('ActionService.deleteLocal', () { test('routes deleted ids to trashed repository when Android trash handling is enabled', () async { await Store.put(StoreKey.manageLocalMediaAndroid, true); diff --git a/mobile/test/unit/utils/timezone_test.dart b/mobile/test/unit/utils/timezone_test.dart index d1e89dc473..90e30972f3 100644 --- a/mobile/test/unit/utils/timezone_test.dart +++ b/mobile/test/unit/utils/timezone_test.dart @@ -177,6 +177,32 @@ void main() { expect(adjustedTime.minute, 30); expect(offset, const Duration(hours: 5, minutes: 30)); }); + + test('should handle UTC-05:30 format (negative offset 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, 6); + expect(adjustedTime.minute, 30); // 12:00 UTC - 5:30 = 06:30 + expect(offset, const Duration(hours: -5, minutes: -30)); + }); + + test('should handle UTC-3:30 format (single digit hour with minutes)', () { + final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0); + + final (adjustedTime, offset) = applyTimezoneOffset( + dateTime: utcTime, + timeZone: 'UTC-3:30', + ); + + expect(adjustedTime.hour, 8); + expect(adjustedTime.minute, 30); // 12:00 UTC - 3:30 = 08:30 + expect(offset, const Duration(hours: -3, minutes: -30)); + }); }); group('with null or invalid timezone', () {