diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 30f92cf8d..32ba52e36 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart new file mode 100644 index 000000000..d24e1ae1c --- /dev/null +++ b/mobile/lib/domain/models/memory.model.dart @@ -0,0 +1,202 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum MemoryTypeEnum { + // do not change this order! + onThisDay, +} + +class MemoryData { + final int year; + + const MemoryData({ + required this.year, + }); + + MemoryData copyWith({ + int? year, + }) { + return MemoryData( + year: year ?? this.year, + ); + } + + Map toMap() { + return { + 'year': year, + }; + } + + factory MemoryData.fromMap(Map map) { + return MemoryData( + year: map['year'] as int, + ); + } + + String toJson() => json.encode(toMap()); + + factory MemoryData.fromJson(String source) => + MemoryData.fromMap(json.decode(source) as Map); + + @override + String toString() => 'MemoryData(year: $year)'; + + @override + bool operator ==(covariant MemoryData other) { + if (identical(this, other)) return true; + + return other.year == year; + } + + @override + int get hashCode => year.hashCode; +} + +// Model for a memory stored in the server +class Memory { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + + // enum + final MemoryTypeEnum type; + final MemoryData data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + + const Memory({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt, + }); + + Memory copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? deletedAt, + String? ownerId, + MemoryTypeEnum? type, + MemoryData? data, + bool? isSaved, + DateTime? memoryAt, + DateTime? seenAt, + DateTime? showAt, + DateTime? hideAt, + }) { + return Memory( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + Map toMap() { + return { + 'id': id, + 'createdAt': createdAt.millisecondsSinceEpoch, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + 'deletedAt': deletedAt?.millisecondsSinceEpoch, + 'ownerId': ownerId, + 'type': type.index, + 'data': data.toMap(), + 'isSaved': isSaved, + 'memoryAt': memoryAt.millisecondsSinceEpoch, + 'seenAt': seenAt?.millisecondsSinceEpoch, + 'showAt': showAt?.millisecondsSinceEpoch, + 'hideAt': hideAt?.millisecondsSinceEpoch, + }; + } + + factory Memory.fromMap(Map map) { + return Memory( + id: map['id'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int), + updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int), + deletedAt: map['deletedAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['deletedAt'] as int) + : null, + ownerId: map['ownerId'] as String, + type: MemoryTypeEnum.values[map['type'] as int], + data: MemoryData.fromMap(map['data'] as Map), + isSaved: map['isSaved'] as bool, + memoryAt: DateTime.fromMillisecondsSinceEpoch(map['memoryAt'] as int), + seenAt: map['seenAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['seenAt'] as int) + : null, + showAt: map['showAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['showAt'] as int) + : null, + hideAt: map['hideAt'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['hideAt'] as int) + : null, + ); + } + + String toJson() => json.encode(toMap()); + + factory Memory.fromJson(String source) => + Memory.fromMap(json.decode(source) as Map); + + @override + String toString() { + return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt)'; + } + + @override + bool operator ==(covariant Memory other) { + if (identical(this, other)) return true; + + return other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.deletedAt == deletedAt && + other.ownerId == ownerId && + other.type == type && + other.data == data && + other.isSaved == isSaved && + other.memoryAt == memoryAt && + other.seenAt == seenAt && + other.showAt == showAt && + other.hideAt == hideAt; + } + + @override + int get hashCode { + return id.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + deletedAt.hashCode ^ + ownerId.hashCode ^ + type.hashCode ^ + data.hashCode ^ + isSaved.hashCode ^ + memoryAt.hashCode ^ + seenAt.hashCode ^ + showAt.hashCode ^ + hideAt.hashCode; + } +} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 2160018df..c4e40726b 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -146,6 +146,14 @@ class SyncStreamService { // to acknowledge that the client has processed all the backfill events case SyncEntityType.syncAckV1: return; + case SyncEntityType.memoryV1: + return _syncStreamRepository.updateMemoriesV1(data.cast()); + case SyncEntityType.memoryDeleteV1: + return _syncStreamRepository.deleteMemoriesV1(data.cast()); + case SyncEntityType.memoryToAssetV1: + return _syncStreamRepository.updateMemoryAssetsV1(data.cast()); + case SyncEntityType.memoryToAssetDeleteV1: + return _syncStreamRepository.deleteMemoryAssetsV1(data.cast()); default: _logger.warning("Unknown sync data type: $type"); } diff --git a/mobile/lib/infrastructure/entities/memory.entity.dart b/mobile/lib/infrastructure/entities/memory.entity.dart new file mode 100644 index 000000000..0e1980210 --- /dev/null +++ b/mobile/lib/infrastructure/entities/memory.entity.dart @@ -0,0 +1,36 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class MemoryEntity extends Table with DriftDefaultsMixin { + const MemoryEntity(); + + TextColumn get id => text()(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get deletedAt => dateTime().nullable()(); + + TextColumn get ownerId => + text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + IntColumn get type => intEnum()(); + + TextColumn get data => text()(); + + BoolColumn get isSaved => boolean().withDefault(const Constant(false))(); + + DateTimeColumn get memoryAt => dateTime()(); + + DateTimeColumn get seenAt => dateTime().nullable()(); + + DateTimeColumn get showAt => dateTime().nullable()(); + + DateTimeColumn get hideAt => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/memory.entity.drift.dart b/mobile/lib/infrastructure/entities/memory.entity.drift.dart new file mode 100644 index 000000000..cb88651ba Binary files /dev/null and b/mobile/lib/infrastructure/entities/memory.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/memory_asset.entity.dart b/mobile/lib/infrastructure/entities/memory_asset.entity.dart new file mode 100644 index 000000000..c304b0372 --- /dev/null +++ b/mobile/lib/infrastructure/entities/memory_asset.entity.dart @@ -0,0 +1,17 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class MemoryAssetEntity extends Table with DriftDefaultsMixin { + const MemoryAssetEntity(); + + TextColumn get assetId => + text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get memoryId => + text().references(MemoryEntity, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {assetId, memoryId}; +} diff --git a/mobile/lib/infrastructure/entities/memory_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/memory_asset.entity.drift.dart new file mode 100644 index 000000000..9253e8bc0 Binary files /dev/null and b/mobile/lib/infrastructure/entities/memory_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index dbe491b03..e71598a99 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; @@ -46,6 +48,8 @@ class IsarDatabaseRepository implements IDatabaseRepository { RemoteAlbumEntity, RemoteAlbumAssetEntity, RemoteAlbumUserEntity, + MemoryEntity, + MemoryAssetEntity, ], include: { 'package:immich_mobile/infrastructure/entities/merged_asset.drift', diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 69fd84b79..925591def 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart new file mode 100644 index 000000000..1f70f9dcd --- /dev/null +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -0,0 +1,37 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftMemoryRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftMemoryRepository(this._db) : super(_db); + + Future> getAll(String userId) { + final query = _db.memoryEntity.select() + ..where((e) => e.ownerId.equals(userId)); + + return query.map((memory) { + return memory.toDto(); + }).get(); + } +} + +extension on MemoryEntityData { + Memory toDto() { + return Memory( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + ownerId: ownerId, + type: type, + data: MemoryData.fromJson(data), + isSaved: isSaved, + memoryAt: memoryAt, + seenAt: seenAt, + showAt: showAt, + hideAt: hideAt, + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index ccc79fa81..99199a7fc 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -52,6 +52,8 @@ class SyncApiRepository { SyncRequestType.albumAssetsV1, SyncRequestType.albumAssetExifsV1, SyncRequestType.albumToAssetsV1, + SyncRequestType.memoriesV1, + SyncRequestType.memoryToAssetsV1, ], ).toJson(), ); @@ -157,6 +159,10 @@ const _kResponseMap = { SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson, SyncEntityType.albumToAssetDeleteV1: SyncAlbumToAssetDeleteV1.fromJson, SyncEntityType.syncAckV1: _SyncAckV1.fromJson, + SyncEntityType.memoryV1: SyncMemoryV1.fromJson, + SyncEntityType.memoryDeleteV1: SyncMemoryDeleteV1.fromJson, + SyncEntityType.memoryToAssetV1: SyncMemoryAssetV1.fromJson, + SyncEntityType.memoryToAssetDeleteV1: SyncMemoryAssetDeleteV1.fromJson, }; class _SyncAckV1 { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 015e09d66..b88083aa0 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -1,12 +1,17 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:logging/logging.dart'; @@ -64,8 +69,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: SyncPartnerDeleteV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: SyncPartnerDeleteV1', error, stackTrace); rethrow; } } @@ -87,8 +92,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: SyncPartnerV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: SyncPartnerV1', error, stackTrace); rethrow; } } @@ -98,10 +103,11 @@ class SyncStreamRepository extends DriftDatabaseRepository { String debugLabel = 'user', }) async { try { - await _db.remoteAssetEntity - .deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId))); - } catch (e, s) { - _logger.severe('Error: deleteAssetsV1 - $debugLabel', e, s); + await _db.remoteAssetEntity.deleteWhere( + (row) => row.id.isIn(data.map((error) => error.assetId)), + ); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stackTrace); rethrow; } } @@ -136,8 +142,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAssetsV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: updateAssetsV1 - $debugLabel', error, stackTrace); rethrow; } } @@ -180,18 +186,23 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAssetsExifV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe( + 'Error: updateAssetsExifV1 - $debugLabel', + error, + stackTrace, + ); rethrow; } } Future deleteAlbumsV1(Iterable data) async { try { - await _db.remoteAlbumEntity - .deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId))); - } catch (e, s) { - _logger.severe('Error: deleteAlbumsV1', e, s); + await _db.remoteAlbumEntity.deleteWhere( + (row) => row.id.isIn(data.map((e) => e.albumId)), + ); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAlbumsV1', error, stackTrace); rethrow; } } @@ -218,8 +229,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAlbumsV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: updateAlbumsV1', error, stackTrace); rethrow; } } @@ -237,8 +248,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: deleteAlbumUsersV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAlbumUsersV1', error, stackTrace); rethrow; } } @@ -264,8 +275,12 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAlbumUsersV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe( + 'Error: updateAlbumUsersV1 - $debugLabel', + error, + stackTrace, + ); rethrow; } } @@ -285,8 +300,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: deleteAlbumToAssetsV1', e, s); + } catch (error, stackTrace) { + _logger.severe('Error: deleteAlbumToAssetsV1', error, stackTrace); rethrow; } } @@ -310,8 +325,96 @@ class SyncStreamRepository extends DriftDatabaseRepository { ); } }); - } catch (e, s) { - _logger.severe('Error: updateAlbumToAssetsV1 - $debugLabel', e, s); + } catch (error, stackTrace) { + _logger.severe( + 'Error: updateAlbumToAssetsV1 - $debugLabel', + error, + stackTrace, + ); + rethrow; + } + } + + Future updateMemoriesV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final memory in data) { + final companion = MemoryEntityCompanion( + createdAt: Value(memory.createdAt), + deletedAt: Value(memory.deletedAt), + ownerId: Value(memory.ownerId), + type: Value(memory.type.toMemoryType()), + data: Value(jsonEncode(memory.data)), + isSaved: Value(memory.isSaved), + memoryAt: Value(memory.memoryAt), + seenAt: Value.absentIfNull(memory.seenAt), + showAt: Value.absentIfNull(memory.showAt), + hideAt: Value.absentIfNull(memory.hideAt), + ); + + batch.insert( + _db.memoryEntity, + companion.copyWith(id: Value(memory.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stackTrace) { + _logger.severe('Error: updateMemoriesV1', error, stackTrace); + rethrow; + } + } + + Future deleteMemoriesV1(Iterable data) async { + try { + await _db.memoryEntity.deleteWhere( + (row) => row.id.isIn(data.map((e) => e.memoryId)), + ); + } catch (error, stackTrace) { + _logger.severe('Error: deleteMemoriesV1', error, stackTrace); + rethrow; + } + } + + Future updateMemoryAssetsV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final asset in data) { + final companion = MemoryAssetEntityCompanion( + memoryId: Value(asset.memoryId), + assetId: Value(asset.assetId), + ); + + batch.insert( + _db.memoryAssetEntity, + companion, + onConflict: DoNothing(), + ); + } + }); + } catch (error, stackTrace) { + _logger.severe('Error: updateMemoryAssetsV1', error, stackTrace); + rethrow; + } + } + + Future deleteMemoryAssetsV1( + Iterable data, + ) async { + try { + await _db.batch((batch) { + for (final asset in data) { + batch.delete( + _db.memoryAssetEntity, + MemoryAssetEntityCompanion( + memoryId: Value(asset.memoryId), + assetId: Value(asset.assetId), + ), + ); + } + }); + } catch (error, stackTrace) { + _logger.severe('Error: deleteMemoryAssetsV1', error, stackTrace); rethrow; } } @@ -335,6 +438,13 @@ extension on AssetOrder { }; } +extension on MemoryType { + MemoryTypeEnum toMemoryType() => switch (this) { + MemoryType.onThisDay => MemoryTypeEnum.onThisDay, + _ => throw Exception('Unknown MemoryType value: $this'), + }; +} + extension on api.AlbumUserRole { AlbumUserRole toAlbumUserRole() => switch (this) { api.AlbumUserRole.editor => AlbumUserRole.editor, @@ -357,7 +467,7 @@ extension on String { Duration? toDuration() { try { final parts = split(':') - .map((e) => double.parse(e).toInt()) + .map((error) => double.parse(error).toInt()) .toList(growable: false); return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]); diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index 10d09f8de..f0a648fd5 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -154,6 +154,14 @@ final _remoteStats = [ name: 'Remote Albums', load: (db) => db.managers.remoteAlbumEntity.count(), ), + _Stat( + name: 'Memories', + load: (db) => db.managers.memoryEntity.count(), + ), + _Stat( + name: 'Memories Assets', + load: (db) => db.managers.memoryAssetEntity.count(), + ), ]; @RoutePage() diff --git a/mobile/lib/providers/memory.provider.dart b/mobile/lib/providers/memory.provider.dart index aed546002..37f84dca9 100644 --- a/mobile/lib/providers/memory.provider.dart +++ b/mobile/lib/providers/memory.provider.dart @@ -1,5 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/services/memory.service.dart'; final memoryFutureProvider = @@ -8,3 +10,7 @@ final memoryFutureProvider = return await service.getMemoryLane(); }); + +final driftMemoryProvider = Provider( + (ref) => DriftMemoryRepository(ref.watch(driftProvider)), +); diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index a0b61bcaf..28288422f 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -81,6 +81,14 @@ void main() { debugLabel: any(named: 'debugLabel'), ), ).thenAnswer(successHandler); + when(() => mockSyncStreamRepo.updateMemoriesV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deleteMemoriesV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deleteMemoryAssetsV1(any())) + .thenAnswer(successHandler); sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, @@ -227,5 +235,94 @@ void main() { verify(() => mockSyncApiRepo.ack(["2"])).called(1); }, ); + + test("processes memory sync events successfully", () async { + final events = [ + SyncStreamStub.memoryV1, + SyncStreamStub.memoryDeleteV1, + SyncStreamStub.memoryToAssetV1, + SyncStreamStub.memoryToAssetDeleteV1, + ]; + + await simulateEvents(events); + + verifyInOrder([ + () => mockSyncStreamRepo.updateMemoriesV1(any()), + () => mockSyncApiRepo.ack(["5"]), + () => mockSyncStreamRepo.deleteMemoriesV1(any()), + () => mockSyncApiRepo.ack(["6"]), + () => mockSyncStreamRepo.updateMemoryAssetsV1(any()), + () => mockSyncApiRepo.ack(["7"]), + () => mockSyncStreamRepo.deleteMemoryAssetsV1(any()), + () => mockSyncApiRepo.ack(["8"]), + ]); + verifyNever(() => mockAbortCallbackWrapper()); + }); + + test("processes mixed memory and user events in correct order", () async { + final events = [ + SyncStreamStub.memoryDeleteV1, + SyncStreamStub.userV1Admin, + SyncStreamStub.memoryToAssetV1, + SyncStreamStub.memoryV1, + ]; + + await simulateEvents(events); + + verifyInOrder([ + () => mockSyncStreamRepo.deleteMemoriesV1(any()), + () => mockSyncApiRepo.ack(["6"]), + () => mockSyncStreamRepo.updateUsersV1(any()), + () => mockSyncApiRepo.ack(["1"]), + () => mockSyncStreamRepo.updateMemoryAssetsV1(any()), + () => mockSyncApiRepo.ack(["7"]), + () => mockSyncStreamRepo.updateMemoriesV1(any()), + () => mockSyncApiRepo.ack(["5"]), + ]); + verifyNever(() => mockAbortCallbackWrapper()); + }); + + test("handles memory sync failure gracefully", () async { + when(() => mockSyncStreamRepo.updateMemoriesV1(any())) + .thenThrow(Exception("Memory sync failed")); + + final events = [ + SyncStreamStub.memoryV1, + SyncStreamStub.userV1Admin, + ]; + + expect( + () async => await simulateEvents(events), + throwsA(isA()), + ); + }); + + test("processes memory asset events with correct data types", () async { + final events = [SyncStreamStub.memoryToAssetV1]; + + await simulateEvents(events); + + verify(() => mockSyncStreamRepo.updateMemoryAssetsV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(["7"])).called(1); + }); + + test("processes memory delete events with correct data types", () async { + final events = [SyncStreamStub.memoryDeleteV1]; + + await simulateEvents(events); + + verify(() => mockSyncStreamRepo.deleteMemoriesV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(["6"])).called(1); + }); + + test("processes memory create/update events with correct data types", + () async { + final events = [SyncStreamStub.memoryV1]; + + await simulateEvents(events); + + verify(() => mockSyncStreamRepo.updateMemoriesV1(any())).called(1); + verify(() => mockSyncApiRepo.ack(["5"])).called(1); + }); }); } diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index ba97f1434..de2d58bc9 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -42,4 +42,47 @@ abstract final class SyncStreamStub { data: SyncPartnerDeleteV1(sharedById: "3", sharedWithId: "4"), ack: "4", ); + + static final memoryV1 = SyncEvent( + type: SyncEntityType.memoryV1, + data: SyncMemoryV1( + createdAt: DateTime(2023, 1, 1), + data: {"year": 2023, "title": "Test Memory"}, + deletedAt: null, + hideAt: null, + id: "memory-1", + isSaved: false, + memoryAt: DateTime(2023, 1, 1), + ownerId: "user-1", + seenAt: null, + showAt: DateTime(2023, 1, 1), + type: MemoryType.onThisDay, + updatedAt: DateTime(2023, 1, 1), + ), + ack: "5", + ); + + static final memoryDeleteV1 = SyncEvent( + type: SyncEntityType.memoryDeleteV1, + data: SyncMemoryDeleteV1(memoryId: "memory-2"), + ack: "6", + ); + + static final memoryToAssetV1 = SyncEvent( + type: SyncEntityType.memoryToAssetV1, + data: SyncMemoryAssetV1( + assetId: "asset-1", + memoryId: "memory-1", + ), + ack: "7", + ); + + static final memoryToAssetDeleteV1 = SyncEvent( + type: SyncEntityType.memoryToAssetDeleteV1, + data: SyncMemoryAssetDeleteV1( + assetId: "asset-2", + memoryId: "memory-1", + ), + ack: "8", + ); }