From 77236949a06814101f9ffb3265a0769cae814b31 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:04:20 +0530 Subject: [PATCH] refactor: partner-page --- mobile/lib/constants/enums.dart | 2 + mobile/lib/domain/models/user.model.dart | 112 ++++++++ .../lib/domain/services/partner.service.dart | 59 ++-- .../entities/partner.entity.dart | 13 + .../infrastructure/entities/user.entity.dart | 10 + .../repositories/partner.repository.dart | 133 +++------ .../repositories/user.repository.dart | 8 + .../library/partner/drift_partner.page.dart | 257 +++++++++++------- .../pages/drift_library.page.dart | 22 +- .../pages/drift_partner_detail.page.dart | 8 +- .../people/partner_user_avatar.widget.dart | 10 +- .../infrastructure/partner.provider.dart | 86 ------ .../infrastructure/user.provider.dart | 20 +- mobile/lib/routing/router.dart | 2 +- mobile/lib/routing/router.gr.dart | 4 +- mobile/test/api.mocks.dart | 3 + mobile/test/domain/service.mock.dart | 3 + .../test/infrastructure/repository.mock.dart | 6 + .../repositories/partner_repository_test.dart | 101 +++++++ mobile/test/medium/repository_context.dart | 13 + mobile/test/medium/service_context.dart | 31 +++ .../medium/services/partner_service_test.dart | 110 ++++++++ .../unit/factories/local_album_factory.dart | 2 +- .../unit/factories/local_asset_factory.dart | 2 +- .../unit/factories/partner_user_factory.dart | 19 ++ mobile/test/unit/factories/user_factory.dart | 26 ++ mobile/test/unit/mocks.dart | 44 ++- .../unit/presentation/partner_page_test.dart | 121 +++++++++ mobile/test/unit/presentation_context.dart | 68 +++++ .../test/unit/services/hash_service_test.dart | 2 +- mobile/test/unit/utils/editor_test.dart | 20 +- mobile/test/unit/utils/semver_test.dart | 2 +- 32 files changed, 946 insertions(+), 373 deletions(-) delete mode 100644 mobile/lib/providers/infrastructure/partner.provider.dart create mode 100644 mobile/test/medium/repositories/partner_repository_test.dart create mode 100644 mobile/test/medium/service_context.dart create mode 100644 mobile/test/medium/services/partner_service_test.dart create mode 100644 mobile/test/unit/factories/partner_user_factory.dart create mode 100644 mobile/test/unit/factories/user_factory.dart create mode 100644 mobile/test/unit/presentation/partner_page_test.dart create mode 100644 mobile/test/unit/presentation_context.dart diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 473bd52b03..902b40b395 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -22,3 +22,5 @@ enum AssetDateAggregation { start, end } enum SlideshowLook { contain, cover, blurredBackground } enum SlideshowDirection { forward, backward, shuffle } + +enum PartnerDirection { sharedBy, sharedWith } diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index 9ed70d61d6..a987ae9958 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -237,3 +237,115 @@ class PartnerUserDto { return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode; } } + +class User { + final String id; + final String name; + final String email; + final DateTime profileChangedAt; + final bool hasProfileImage; + final AvatarColor? avatarColor; + + const User({ + required this.id, + required this.name, + required this.email, + required this.profileChangedAt, + required this.hasProfileImage, + this.avatarColor = AvatarColor.primary, + }); + + @override + String toString() { + return 'User(id: $id, name: $name, email: $email, profileChangedAt: $profileChangedAt, hasProfileImage: $hasProfileImage, avatarColor: $avatarColor)'; + } + + @override + bool operator ==(covariant User other) { + if (identical(this, other)) { + return true; + } + + return other.id == id && + other.name == name && + other.email == email && + other.profileChangedAt == profileChangedAt && + other.hasProfileImage == hasProfileImage && + other.avatarColor == avatarColor; + } + + @override + int get hashCode => Object.hash(id, name, email, profileChangedAt, hasProfileImage, avatarColor); +} + +class AuthUser extends User { + final bool isAdmin; + final String? pinCode; + final int? quotaSizeInBytes; + final int quotaUsageInBytes; + + const AuthUser({ + required super.id, + required super.name, + required super.email, + required super.profileChangedAt, + required super.hasProfileImage, + super.avatarColor, + this.isAdmin = false, + this.pinCode, + this.quotaSizeInBytes = 0, + this.quotaUsageInBytes = 0, + }); + + @override + String toString() { + return 'AuthUser(user: ${super.toString()}, isAdmin: $isAdmin, pinCode: $pinCode, quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes: $quotaUsageInBytes)'; + } + + @override + bool operator ==(covariant AuthUser other) { + if (identical(this, other)) { + return true; + } + + return super == other && + other.isAdmin == isAdmin && + other.pinCode == pinCode && + other.quotaSizeInBytes == quotaSizeInBytes && + other.quotaUsageInBytes == quotaUsageInBytes; + } + + @override + int get hashCode => Object.hash(super.hashCode, isAdmin, pinCode, quotaSizeInBytes, quotaUsageInBytes); +} + +class Partner extends User { + final bool inTimeline; + + const Partner({ + required super.id, + required super.name, + required super.email, + required super.profileChangedAt, + required super.hasProfileImage, + super.avatarColor, + this.inTimeline = false, + }); + + @override + String toString() { + return 'Partner(user: ${super.toString()}, inTimeline: $inTimeline)'; + } + + @override + bool operator ==(covariant Partner other) { + if (identical(this, other)) { + return true; + } + + return super == other && other.inTimeline == inTimeline; + } + + @override + int get hashCode => Object.hash(super.hashCode, inTimeline); +} diff --git a/mobile/lib/domain/services/partner.service.dart b/mobile/lib/domain/services/partner.service.dart index ce1bd9557b..c9acd6ae10 100644 --- a/mobile/lib/domain/services/partner.service.dart +++ b/mobile/lib/domain/services/partner.service.dart @@ -1,51 +1,42 @@ +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; +import 'package:stream_transform/stream_transform.dart'; -class DriftPartnerService { - final DriftPartnerRepository _driftPartnerRepository; +class PartnerService { + final UserRepository _userRepository; + final PartnerRepository _partnerRepository; final PartnerApiRepository _partnerApiRepository; - const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository); + const PartnerService(this._userRepository, this._partnerRepository, this._partnerApiRepository); - Future> getSharedWith(String userId) { - return _driftPartnerRepository.getSharedWith(userId); + Stream> getCandidates(String userId) { + final userStream = _userRepository.getAll(); + final partnerStream = _partnerRepository.search(userId, .sharedBy); + + return userStream.combineLatest(partnerStream, (users, partners) { + final partnersSet = partners.map((partner) => partner.id).toSet(); + return users.where((user) => user.id != userId && !partnersSet.contains(user.id)); + }); } - Future> getSharedBy(String userId) { - return _driftPartnerRepository.getSharedBy(userId); + Stream> search(String userId, PartnerDirection direction) => + _partnerRepository.search(userId, direction); + + Future update(String partnerId, String userId, {required bool inTimeline}) async { + await _partnerApiRepository.update(partnerId, inTimeline: inTimeline); + await _partnerRepository.update(partnerId, userId, inTimeline: inTimeline); } - Future> getAvailablePartners(String currentUserId) async { - final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId); - final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId); - final available = otherUsers.where((user) { - return !currentPartners.any((partner) => partner.id == user.id); - }).toList(); - - return available; - } - - Future toggleShowInTimeline(String partnerId, String userId) async { - final partner = await _driftPartnerRepository.getPartner(partnerId, userId); - if (partner == null) { - dPrint(() => "Partner not found: $partnerId for user: $userId"); - return; - } - - await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline); - - await _driftPartnerRepository.toggleShowInTimeline(partner, userId); - } - - Future addPartner(String partnerId, String userId) async { + Future create(String partnerId, String userId) async { await _partnerApiRepository.create(partnerId); - await _driftPartnerRepository.create(partnerId, userId); + await _partnerRepository.create(partnerId, userId); } - Future removePartner(String partnerId, String userId) async { + Future delete(String partnerId, String userId) async { await _partnerApiRepository.delete(partnerId); - await _driftPartnerRepository.delete(partnerId, userId); + await _partnerRepository.delete(partnerId, userId); } } diff --git a/mobile/lib/infrastructure/entities/partner.entity.dart b/mobile/lib/infrastructure/entities/partner.entity.dart index 1d8dc6d87c..cbdf5796b0 100644 --- a/mobile/lib/infrastructure/entities/partner.entity.dart +++ b/mobile/lib/infrastructure/entities/partner.entity.dart @@ -1,5 +1,8 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)') @@ -14,4 +17,14 @@ class PartnerEntity extends Table with DriftDefaultsMixin { @override Set get primaryKey => {sharedById, sharedWithId}; + + static Partner rowToPartner(UserEntityData user, PartnerEntityData partner) => Partner( + id: user.id, + email: user.email, + name: user.name, + profileChangedAt: user.profileChangedAt, + hasProfileImage: user.hasProfileImage, + avatarColor: user.avatarColor, + inTimeline: partner.inTimeline, + ); } diff --git a/mobile/lib/infrastructure/entities/user.entity.dart b/mobile/lib/infrastructure/entities/user.entity.dart index 8d4371672c..a4e9c5c636 100644 --- a/mobile/lib/infrastructure/entities/user.entity.dart +++ b/mobile/lib/infrastructure/entities/user.entity.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart' hide Index; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; class UserEntity extends Table with DriftDefaultsMixin { @@ -16,4 +17,13 @@ class UserEntity extends Table with DriftDefaultsMixin { @override Set get primaryKey => {id}; + + static User rowToUser(UserEntityData row) => User( + id: row.id, + name: row.name, + email: row.email, + profileChangedAt: row.profileChangedAt, + hasProfileImage: row.hasProfileImage, + avatarColor: row.avatarColor, + ); } diff --git a/mobile/lib/infrastructure/repositories/partner.repository.dart b/mobile/lib/infrastructure/repositories/partner.repository.dart index b12061ad24..3f6a88199a 100644 --- a/mobile/lib/infrastructure/repositories/partner.repository.dart +++ b/mobile/lib/infrastructure/repositories/partner.repository.dart @@ -1,106 +1,55 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -class DriftPartnerRepository extends DriftDatabaseRepository { +class PartnerRepository { final Drift _db; - const DriftPartnerRepository(this._db) : super(_db); + const PartnerRepository(this._db); - Future> getPartners(String userId) { - final query = _db.select(_db.partnerEntity).join([ - innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)), - ])..where(_db.partnerEntity.sharedWithId.equals(userId)); - - return query.map((row) { - final user = row.readTable(_db.userEntity); - final partner = row.readTable(_db.partnerEntity); - return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline); - }).get(); + Partner _resultToPartner(TypedResult result) { + final user = result.readTable(_db.userEntity); + final partner = result.readTable(_db.partnerEntity); + return PartnerEntity.rowToPartner(user, partner); } - // Get users who we can share our library with - Future> getAvailablePartners(String currentUserId) { - final query = _db.select(_db.userEntity)..where((row) => row.id.equals(currentUserId).not()); + Future get(String partnerId, String userId) => + (_db.select(_db.partnerEntity).join([ + innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)), + ])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId))) + .map(_resultToPartner) + .getSingle(); - return query.map((user) { - return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false); - }).get(); - } + Stream> search(String userId, PartnerDirection direction) => + (_db.select(_db.partnerEntity).join([ + innerJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(switch (direction) { + .sharedBy => _db.partnerEntity.sharedWithId, + .sharedWith => _db.partnerEntity.sharedById, + }), + ), + ])..where( + switch (direction) { + .sharedBy => _db.partnerEntity.sharedById, + .sharedWith => _db.partnerEntity.sharedWithId, + }.equals(userId) & + _db.userEntity.id.equals(userId).not(), + )) + .map(_resultToPartner) + .watch(); - // Get users who are sharing their photos WITH the current user - Future> getSharedWith(String partnerId) { - final query = _db.select(_db.partnerEntity).join([ - innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)), - ])..where(_db.partnerEntity.sharedWithId.equals(partnerId)); + Future create(String partnerId, String userId) => _db.partnerEntity.insertOnConflictUpdate( + PartnerEntityCompanion(sharedById: Value(userId), sharedWithId: Value(partnerId), inTimeline: const Value(false)), + ); - return query.map((row) { - final user = row.readTable(_db.userEntity); - final partner = row.readTable(_db.partnerEntity); - return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline); - }).get(); - } + Future update(String partnerId, String userId, {required bool inTimeline}) => + (_db.partnerEntity.update()..where((t) => t.sharedById.equals(partnerId) & t.sharedWithId.equals(userId))).write( + PartnerEntityCompanion(inTimeline: Value(inTimeline)), + ); - // Get users who the current user is sharing their photos TO - Future> getSharedBy(String userId) { - final query = _db.select(_db.partnerEntity).join([ - innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId)), - ])..where(_db.partnerEntity.sharedById.equals(userId)); - - return query.map((row) { - final user = row.readTable(_db.userEntity); - final partner = row.readTable(_db.partnerEntity); - return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline); - }).get(); - } - - Future> getAllPartnerIds(String userId) async { - // Get users who are sharing with me (sharedWithId = userId) - final sharingWithMeQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedWithId.equals(userId)); - final sharingWithMe = await sharingWithMeQuery.map((row) => row.sharedById).get(); - - // Get users who I am sharing with (sharedById = userId) - final sharingWithThemQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedById.equals(userId)); - final sharingWithThem = await sharingWithThemQuery.map((row) => row.sharedWithId).get(); - - // Combine both lists and remove duplicates - final allPartnerIds = {...sharingWithMe, ...sharingWithThem}.toList(); - return allPartnerIds; - } - - Future getPartner(String partnerId, String userId) { - final query = _db.select(_db.partnerEntity).join([ - innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)), - ])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId)); - - return query.map((row) { - final user = row.readTable(_db.userEntity); - final partner = row.readTable(_db.partnerEntity); - return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline); - }).getSingleOrNull(); - } - - Future toggleShowInTimeline(PartnerUserDto partner, String userId) { - return _db.partnerEntity.update().replace( - PartnerEntityCompanion( - sharedById: Value(partner.id), - sharedWithId: Value(userId), - inTimeline: Value(!partner.inTimeline), - ), - ); - } - - Future create(String partnerId, String userId) { - final entity = PartnerEntityCompanion( - sharedById: Value(userId), - sharedWithId: Value(partnerId), - inTimeline: const Value(false), - ); - - return _db.partnerEntity.insertOne(entity); - } - - Future delete(String partnerId, String userId) { - return _db.partnerEntity.deleteWhere((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId)); - } + Future delete(String partnerId, String userId) => + (_db.partnerEntity.delete()..where((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId))).go(); } diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index afcf2271dd..2cdd0c26e7 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -2,9 +2,17 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; +class UserRepository { + final Drift _db; + const UserRepository(this._db); + + Stream> getAll() => _db.select(_db.userEntity).map(UserEntity.rowToUser).watch(); +} + class DriftAuthUserRepository extends DriftDatabaseRepository { final Drift _db; const DriftAuthUserRepository(super.db) : _db = db; diff --git a/mobile/lib/pages/library/partner/drift_partner.page.dart b/mobile/lib/pages/library/partner/drift_partner.page.dart index a24323c02a..888ac15cc1 100644 --- a/mobile/lib/pages/library/partner/drift_partner.page.dart +++ b/mobile/lib/pages/library/partner/drift_partner.page.dart @@ -3,137 +3,192 @@ 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/user.model.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +@visibleForTesting +final candidatesProvider = StreamProvider.autoDispose>((ref) { + final currentUser = ref.watch(currentUserProvider); + // TODO: Refactor with a route guard to avoid this check in every provider + if (currentUser == null) { + return const Stream.empty(); + } + return ref.watch(partnerServiceProvider).getCandidates(currentUser.id); +}); + +@visibleForTesting +final partnersProvider = StreamProvider.autoDispose>((ref) { + final currentUser = ref.watch(currentUserProvider); + // TODO: Refactor with a route guard to avoid this check in every provider + if (currentUser == null) { + return const Stream.empty(); + } + + return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedBy); +}); @RoutePage() -class DriftPartnerPage extends HookConsumerWidget { +class DriftPartnerPage extends ConsumerWidget { const DriftPartnerPage({super.key}); + Future _addPartner(BuildContext context, WidgetRef ref) async { + final selected = await showDialog(context: context, builder: (_) => const PartnerSelectionDialog()); + final currentUser = ref.read(currentUserProvider); + if (selected != null && currentUser != null) { + await ref.read(partnerServiceProvider).create(selected.id, currentUser.id); + } + } + + Future _removePartner(BuildContext context, WidgetRef ref, Partner partner) => showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: "stop_photo_sharing", + content: context.t.partner_page_stop_sharing_content(partner: partner.name), + onOk: () { + final currentUser = ref.read(currentUserProvider); + if (currentUser != null) { + ref.read(partnerServiceProvider).delete(partner.id, currentUser.id); + } + }, + ), + ); + @override Widget build(BuildContext context, WidgetRef ref) { - final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider); - - addNewUsersHandler() async { - final potentialPartners = potentialPartnersAsync.value; - if (potentialPartners == null || potentialPartners.isEmpty) { - ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr()); - return; - } - - final selectedUser = await showDialog( - context: context, - builder: (context) { - return SimpleDialog( - title: const Text("partner_page_select_partner").tr(), - children: [ - for (PartnerUserDto partner in potentialPartners) - SimpleDialogOption( - onPressed: () => context.pop(partner), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: PartnerUserAvatar(partner: partner), - ), - Text(partner.name), - ], - ), - ), - ], - ); - }, - ); - if (selectedUser != null) { - await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser); - } - } - - onDeleteUser(PartnerUserDto partner) { - return showDialog( - context: context, - builder: (BuildContext context) { - return ConfirmDialog( - title: "stop_photo_sharing", - content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': partner.name}), - onOk: () => ref.read(partnerUsersProvider.notifier).removePartner(partner), - ); - }, - ); - } + final sharedByAsync = ref.watch(partnersProvider); return Scaffold( appBar: AppBar( - title: const Text("partners").t(context: context), + title: Text(context.t.partners), elevation: 0, centerTitle: false, actions: [ IconButton( - onPressed: potentialPartnersAsync.whenOrNull(data: (data) => addNewUsersHandler), + onPressed: () => _addPartner(context, ref), icon: const Icon(Icons.person_add), - tooltip: "add_partner".tr(), + tooltip: context.t.add_partner, ), ], ), - body: _SharedToPartnerList(onAddPartner: addNewUsersHandler, onDeletePartner: onDeleteUser), + body: sharedByAsync.when( + data: (partners) => PartnerSharedByList( + partners: partners.toList(growable: false), + onAddPartner: () => _addPartner(context, ref), + onRemovePartner: (partner) => _removePartner(context, ref, partner), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))), + ), ); } } -class _SharedToPartnerList extends ConsumerWidget { - final VoidCallback onAddPartner; - final Function(PartnerUserDto partner) onDeletePartner; +@visibleForTesting +class PartnerSharedByList extends StatelessWidget { + const PartnerSharedByList({ + super.key, + required this.partners, + required this.onAddPartner, + required this.onRemovePartner, + }); - const _SharedToPartnerList({required this.onAddPartner, required this.onDeletePartner}); + final List partners; + final VoidCallback onAddPartner; + final ValueChanged onRemovePartner; + + @override + Widget build(BuildContext context) { + if (partners.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)), + ), + Align( + alignment: Alignment.center, + child: ElevatedButton.icon( + onPressed: onAddPartner, + icon: const Icon(Icons.person_add), + label: Text(context.t.add_partner), + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: partners.length, + itemBuilder: (_, index) { + final partner = partners[index]; + return ListTile( + leading: PartnerUserAvatar(userId: partner.id, name: partner.name), + title: Text(partner.name), + subtitle: Text(partner.email), + trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onRemovePartner(partner)), + ); + }, + ); + } +} + +@visibleForTesting +class PartnerSelectionDialog extends ConsumerWidget { + const PartnerSelectionDialog({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final partnerAsync = ref.watch(driftSharedByPartnerProvider); + final candidatesAsync = ref.watch(candidatesProvider); - return partnerAsync.when( - data: (partners) { - if (partners.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(), + return SimpleDialog( + title: const Text("partner_page_select_partner").tr(), + children: candidatesAsync.when( + data: (candidates) { + final users = candidates.toList(); + if (users.isEmpty) { + return [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: const Text("partner_page_no_more_users").tr(), + ), + ]; + } + return [ + for (final candidate in users) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(candidate), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: PartnerUserAvatar(userId: candidate.id, name: candidate.name), + ), + Text(candidate.name), + ], ), - Align( - alignment: Alignment.center, - child: ElevatedButton.icon( - onPressed: onAddPartner, - icon: const Icon(Icons.person_add), - label: const Text("add_partner").tr(), - ), - ), - ], - ), - ); - } - - return ListView.builder( - itemCount: partners.length, - itemBuilder: (context, index) { - final partner = partners[index]; - return ListTile( - leading: PartnerUserAvatar(partner: partner), - title: Text(partner.name), - subtitle: Text(partner.email), - trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onDeletePartner(partner)), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))), + ), + ]; + }, + loading: () => const [ + Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ], + error: (error, _) => [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text("error_loading_partners".tr(args: [error.toString()])), + ), + ], + ), ); } } diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 4708b5e615..6d89dda67b 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -7,12 +7,13 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; @@ -327,6 +328,17 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { } } +@visibleForTesting +final driftSharedWithPartnerProvider = StreamProvider.autoDispose>((ref) { + final currentUser = ref.watch(currentUserProvider); + if (currentUser == null) { + // TODO: Refactor with a route guard to avoid this check in every provider + return const Stream.empty(); + } + + return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith); +}); + class _QuickAccessButtonList extends ConsumerWidget { const _QuickAccessButtonList(); @@ -389,7 +401,7 @@ class _QuickAccessButtonList extends ConsumerWidget { ), onTap: () => context.pushRoute(const DriftPartnerRoute()), ), - _PartnerList(partners: partners), + _PartnerList(partners: partners.toList()), ], ), ), @@ -401,7 +413,7 @@ class _QuickAccessButtonList extends ConsumerWidget { class _PartnerList extends StatelessWidget { const _PartnerList({required this.partners}); - final List partners; + final List partners; @override Widget build(BuildContext context) { @@ -421,7 +433,7 @@ class _PartnerList extends StatelessWidget { ), ), contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0), - leading: PartnerUserAvatar(partner: partner), + leading: PartnerUserAvatar(userId: partner.id, name: partner.name), title: const Text( "partner_list_user_photos", style: TextStyle(fontWeight: FontWeight.w500), diff --git a/mobile/lib/presentation/pages/drift_partner_detail.page.dart b/mobile/lib/presentation/pages/drift_partner_detail.page.dart index f8a19b6b70..5b002a21e3 100644 --- a/mobile/lib/presentation/pages/drift_partner_detail.page.dart +++ b/mobile/lib/presentation/pages/drift_partner_detail.page.dart @@ -8,13 +8,13 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; @RoutePage() class DriftPartnerDetailPage extends StatelessWidget { - final PartnerUserDto partner; + final Partner partner; const DriftPartnerDetailPage({super.key, required this.partner}); @@ -39,7 +39,7 @@ class DriftPartnerDetailPage extends StatelessWidget { } class _InfoBox extends ConsumerStatefulWidget { - final PartnerUserDto partner; + final Partner partner; const _InfoBox({required this.partner}); @@ -63,7 +63,7 @@ class _InfoBoxState extends ConsumerState<_InfoBox> { } try { - await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline(widget.partner.id, user.id); + await ref.read(partnerServiceProvider).update(widget.partner.id, user.id, inTimeline: !_inTimeline); setState(() { _inTimeline = !_inTimeline; diff --git a/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart b/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart index 8b391d50c6..8618d78362 100644 --- a/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart +++ b/mobile/lib/presentation/widgets/people/partner_user_avatar.widget.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; class PartnerUserAvatar extends StatelessWidget { - const PartnerUserAvatar({super.key, required this.partner}); + const PartnerUserAvatar({super.key, required this.userId, required this.name}); - final PartnerUserDto partner; + final String userId; + final String name; @override Widget build(BuildContext context) { - final url = "${Store.get(StoreKey.serverEndpoint)}/users/${partner.id}/profile-image"; - final nameFirstLetter = partner.name.isNotEmpty ? partner.name[0] : ""; + final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image"; + final nameFirstLetter = name.isNotEmpty ? name[0] : ""; return CircleAvatar( radius: 16, backgroundColor: context.primaryColor.withAlpha(50), diff --git a/mobile/lib/providers/infrastructure/partner.provider.dart b/mobile/lib/providers/infrastructure/partner.provider.dart deleted file mode 100644 index ac3d74d85b..0000000000 --- a/mobile/lib/providers/infrastructure/partner.provider.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/domain/services/partner.service.dart'; -import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; - -class PartnerNotifier extends Notifier> { - late DriftPartnerService _driftPartnerService; - - @override - List build() { - _driftPartnerService = ref.read(driftPartnerServiceProvider); - return []; - } - - Future _loadPartners() async { - final currentUser = ref.read(currentUserProvider); - if (currentUser == null) { - return; - } - - state = await _driftPartnerService.getSharedWith(currentUser.id); - } - - Future> getPartners(String userId) async { - final partners = await _driftPartnerService.getSharedWith(userId); - state = partners; - return partners; - } - - Future toggleShowInTimeline(String partnerId, String userId) async { - await _driftPartnerService.toggleShowInTimeline(partnerId, userId); - await _loadPartners(); - } - - Future addPartner(PartnerUserDto partner) async { - final currentUser = ref.read(currentUserProvider); - if (currentUser == null) { - return; - } - - await _driftPartnerService.addPartner(partner.id, currentUser.id); - await _loadPartners(); - ref.invalidate(driftAvailablePartnerProvider); - ref.invalidate(driftSharedByPartnerProvider); - } - - Future removePartner(PartnerUserDto partner) async { - final currentUser = ref.read(currentUserProvider); - if (currentUser == null) { - return; - } - - await _driftPartnerService.removePartner(partner.id, currentUser.id); - await _loadPartners(); - ref.invalidate(driftAvailablePartnerProvider); - ref.invalidate(driftSharedByPartnerProvider); - } -} - -final driftAvailablePartnerProvider = FutureProvider.autoDispose>((ref) { - final currentUser = ref.watch(currentUserProvider); - if (currentUser == null) { - return []; - } - - return ref.watch(driftPartnerServiceProvider).getAvailablePartners(currentUser.id); -}); - -final driftSharedByPartnerProvider = FutureProvider.autoDispose>((ref) { - final currentUser = ref.watch(currentUserProvider); - if (currentUser == null) { - return []; - } - - return ref.watch(driftPartnerServiceProvider).getSharedBy(currentUser.id); -}); - -final driftSharedWithPartnerProvider = FutureProvider.autoDispose>((ref) { - final currentUser = ref.watch(currentUserProvider); - if (currentUser == null) { - return []; - } - - return ref.watch(driftPartnerServiceProvider).getSharedWith(currentUser.id); -}); diff --git a/mobile/lib/providers/infrastructure/user.provider.dart b/mobile/lib/providers/infrastructure/user.provider.dart index d8e7029f8c..09f74db37d 100644 --- a/mobile/lib/providers/infrastructure/user.provider.dart +++ b/mobile/lib/providers/infrastructure/user.provider.dart @@ -1,15 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; +final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider))); + final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi)); final userServiceProvider = Provider( @@ -19,13 +20,12 @@ final userServiceProvider = Provider( ), ); -/// Drifts -final driftPartnerRepositoryProvider = Provider( - (ref) => DriftPartnerRepository(ref.watch(driftProvider)), -); +final partnerRepositoryProvider = Provider((ref) => PartnerRepository(ref.watch(driftProvider))); -final driftPartnerServiceProvider = Provider( - (ref) => DriftPartnerService(ref.watch(driftPartnerRepositoryProvider), ref.watch(partnerApiRepositoryProvider)), +final partnerServiceProvider = Provider( + (ref) => PartnerService( + ref.watch(userRepositoryProvider), + ref.watch(partnerRepositoryProvider), + ref.watch(partnerApiRepositoryProvider), + ), ); - -final partnerUsersProvider = NotifierProvider>(PartnerNotifier.new); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b39a568e26..b7e6805ab3 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -57,8 +57,8 @@ import 'package:immich_mobile/presentation/pages/drift_people_collection.page.da import 'package:immich_mobile/presentation/pages/drift_person.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4b538d789..e6369cda5e 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -827,7 +827,7 @@ class DriftPartnerDetailRoute extends PageRouteInfo { DriftPartnerDetailRoute({ Key? key, - required PartnerUserDto partner, + required Partner partner, List? children, }) : super( DriftPartnerDetailRoute.name, @@ -851,7 +851,7 @@ class DriftPartnerDetailRouteArgs { final Key? key; - final PartnerUserDto partner; + final Partner partner; @override String toString() { diff --git a/mobile/test/api.mocks.dart b/mobile/test/api.mocks.dart index e1c32eaaee..91e27735ae 100644 --- a/mobile/test/api.mocks.dart +++ b/mobile/test/api.mocks.dart @@ -1,6 +1,9 @@ +import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:openapi/api.dart'; class MockSyncApi extends Mock implements SyncApi {} class MockServerApi extends Mock implements ServerApi {} + +class MockPartnerApiRepository extends Mock implements PartnerApiRepository {} diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 89e85a3794..743d75f1bf 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; @@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockAppSettingsService extends Mock implements AppSettingsService {} + +class MockPartnerService extends Mock implements PartnerService {} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 9c1cdae416..0688576682 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -2,6 +2,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; @@ -11,6 +12,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; @@ -44,6 +46,10 @@ class MockUploadRepository extends Mock implements UploadRepository {} class MockSyncMigrationRepository extends Mock implements SyncMigrationRepository {} +class MockUserRepository extends Mock implements UserRepository {} + +class MockPartnerRepository extends Mock implements PartnerRepository {} + // API Repos class MockUserApiRepository extends Mock implements UserApiRepository {} diff --git a/mobile/test/medium/repositories/partner_repository_test.dart b/mobile/test/medium/repositories/partner_repository_test.dart new file mode 100644 index 0000000000..e1649be227 --- /dev/null +++ b/mobile/test/medium/repositories/partner_repository_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; + +import '../repository_context.dart'; + +void main() { + late MediumRepositoryContext ctx; + late PartnerRepository sut; + + setUp(() { + ctx = MediumRepositoryContext(); + sut = PartnerRepository(ctx.db); + }); + + tearDown(() async { + await ctx.dispose(); + }); + + group('search', () { + test('sharedBy returns users the current user shares their library to', () async { + final me = await ctx.newUser(); + final recipient = await ctx.newUser(); + final sharer = await ctx.newUser(); + await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); + await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id); + + final result = await sut.search(me.id, PartnerDirection.sharedBy).first; + + expect(result.map((partner) => partner.id), unorderedEquals([recipient.id])); + }); + + test('sharedWith returns users sharing their library with the current user', () async { + final me = await ctx.newUser(); + final recipient = await ctx.newUser(); + final sharer = await ctx.newUser(); + await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); + await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id); + + final result = await sut.search(me.id, PartnerDirection.sharedWith).first; + + expect(result.map((partner) => partner.id), unorderedEquals([sharer.id])); + }); + + test('emits an updated list when a new partner is added', () async { + final me = await ctx.newUser(); + final recipient = await ctx.newUser(); + + final ids = sut.search(me.id, PartnerDirection.sharedBy).map((partners) => partners.map((p) => p.id).toList()); + final expectation = expectLater( + ids, + emitsInOrder([ + isEmpty, + unorderedEquals([recipient.id]), + ]), + ); + + await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); + await expectation; + }); + }); + + group('create', () { + test('inserts a partnership with the current user as the sharer and inTimeline disabled', () async { + final me = await ctx.newUser(); + final partner = await ctx.newUser(); + + await sut.create(partner.id, me.id); + + final result = (await sut.search(me.id, .sharedBy).first).first; + expect(result.id, partner.id); + expect(result.inTimeline, isFalse); + }); + }); + + group('update', () { + test('toggles the inTimeline flag for an existing partnership', () async { + final me = await ctx.newUser(); + final sharer = await ctx.newUser(); + await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false); + + await sut.update(sharer.id, me.id, inTimeline: true); + + final result = await sut.get(sharer.id, me.id); + expect(result.inTimeline, isTrue); + }); + }); + + group('delete', () { + test('removes the partnership the current user shares by', () async { + final me = await ctx.newUser(); + final recipient = await ctx.newUser(); + await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); + + await sut.delete(recipient.id, me.id); + + final rows = await ctx.db.select(ctx.db.partnerEntity).get(); + expect(rows, isEmpty); + }); + }); +} diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 13f9a0234e..436a58aaf8 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/person.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'; @@ -68,6 +69,18 @@ class MediumRepositoryContext { ); } + Future newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) { + return db + .into(db.partnerEntity) + .insert( + PartnerEntityCompanion( + sharedById: .new(sharedById), + sharedWithId: .new(sharedWithId), + inTimeline: .new(inTimeline ?? false), + ), + ); + } + Future newRemoteAsset({ String? id, String? checksum, diff --git a/mobile/test/medium/service_context.dart b/mobile/test/medium/service_context.dart new file mode 100644 index 0000000000..6f90b3e344 --- /dev/null +++ b/mobile/test/medium/service_context.dart @@ -0,0 +1,31 @@ +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../api.mocks.dart'; +import '../utils.dart'; +import 'repository_context.dart'; + +void _stubPartnerApi(MockPartnerApiRepository api) { + final id = TestUtils.uuid(); + final partner = UserDto(id: id, email: '$id@example.com', name: 'name $id', profileChangedAt: TestUtils.now()); + + registerFallbackValue(Direction.sharedByMe); + when(() => api.getAll(any())).thenAnswer((_) async => const []); + when(() => api.create(any())).thenAnswer((_) async => partner); + when(() => api.update(any(), inTimeline: any(named: 'inTimeline'))).thenAnswer((_) async => partner); + when(() => api.delete(any())).thenAnswer((_) async {}); +} + +class MediumServiceContext extends MediumRepositoryContext { + late final UserRepository userRepository = UserRepository(db); + late final PartnerRepository partnerRepository = PartnerRepository(db); + + final partnerApi = MockPartnerApiRepository(); + + MediumServiceContext() { + _stubPartnerApi(partnerApi); + } +} diff --git a/mobile/test/medium/services/partner_service_test.dart b/mobile/test/medium/services/partner_service_test.dart new file mode 100644 index 0000000000..1f63df566a --- /dev/null +++ b/mobile/test/medium/services/partner_service_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/services/partner.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../service_context.dart'; + +void main() { + late MediumServiceContext ctx; + late PartnerService sut; + + setUp(() { + ctx = MediumServiceContext(); + sut = PartnerService(ctx.userRepository, ctx.partnerRepository, ctx.partnerApi); + }); + + tearDown(() async { + await ctx.dispose(); + }); + + group('getCandidates', () { + test('returns the other users and excludes the current user', () async { + final me = await ctx.newUser(); + final other = await ctx.newUser(); + + final result = await sut.getCandidates(me.id).first; + + expect(result.map((user) => user.id), unorderedEquals([other.id])); + }); + + test('excludes users the current user already shares with', () async { + final me = await ctx.newUser(); + final partner = await ctx.newUser(); + final other = await ctx.newUser(); + await ctx.newPartner(sharedById: me.id, sharedWithId: partner.id); + + final result = await sut.getCandidates(me.id).first; + + expect(result.map((user) => user.id), unorderedEquals([other.id])); + }); + + test('includes users who share with the current user but are not shared with back', () async { + final me = await ctx.newUser(); + final inbound = await ctx.newUser(); + await ctx.newPartner(sharedById: inbound.id, sharedWithId: me.id); + + final result = await sut.getCandidates(me.id).first; + + expect(result.map((user) => user.id), unorderedEquals([inbound.id])); + }); + + test('emits an updated list when the current user adds a partner', () async { + final me = await ctx.newUser(); + final a = await ctx.newUser(); + final b = await ctx.newUser(); + + final ids = sut.getCandidates(me.id).map((users) => users.map((user) => user.id).toList()); + final expectation = expectLater( + ids, + emitsInOrder([ + unorderedEquals([a.id, b.id]), + unorderedEquals([b.id]), + ]), + ); + + await ctx.newPartner(sharedById: me.id, sharedWithId: a.id); + await expectation; + }); + }); + + group('create', () { + test('calls the API then persists the partnership locally', () async { + final me = await ctx.newUser(); + final partner = await ctx.newUser(); + + await sut.create(partner.id, me.id); + + verify(() => ctx.partnerApi.create(partner.id)).called(1); + final shared = await sut.search(me.id, .sharedBy).first; + expect(shared.map((p) => p.id), unorderedEquals([partner.id])); + }); + }); + + group('delete', () { + test('calls the API then removes the partnership locally', () async { + final me = await ctx.newUser(); + final recipient = await ctx.newUser(); + await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); + + await sut.delete(recipient.id, me.id); + + verify(() => ctx.partnerApi.delete(recipient.id)).called(1); + final shared = await sut.search(me.id, .sharedBy).first; + expect(shared, isEmpty); + }); + }); + + group('update', () { + test('calls the API then updates the inTimeline flag locally', () async { + final me = await ctx.newUser(); + final sharer = await ctx.newUser(); + await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false); + + await sut.update(sharer.id, me.id, inTimeline: true); + + verify(() => ctx.partnerApi.update(sharer.id, inTimeline: true)).called(1); + final partner = await ctx.partnerRepository.get(sharer.id, me.id); + expect(partner.inTimeline, isTrue); + }); + }); +} diff --git a/mobile/test/unit/factories/local_album_factory.dart b/mobile/test/unit/factories/local_album_factory.dart index 8ac5c11eca..447001f971 100644 --- a/mobile/test/unit/factories/local_album_factory.dart +++ b/mobile/test/unit/factories/local_album_factory.dart @@ -19,7 +19,7 @@ class LocalAlbumFactory { id: id, name: name ?? 'local_album_$id', updatedAt: TestUtils.date(updatedAt), - backupSelection: backupSelection ?? BackupSelection.none, + backupSelection: backupSelection ?? .none, isIosSharedAlbum: isIosSharedAlbum ?? false, linkedRemoteAlbumId: linkedRemoteAlbumId, assetCount: assetCount ?? 10, diff --git a/mobile/test/unit/factories/local_asset_factory.dart b/mobile/test/unit/factories/local_asset_factory.dart index 8ad35725c4..2f4391813f 100644 --- a/mobile/test/unit/factories/local_asset_factory.dart +++ b/mobile/test/unit/factories/local_asset_factory.dart @@ -14,7 +14,7 @@ class LocalAssetFactory { type: AssetType.image, createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), - playbackStyle: AssetPlaybackStyle.image, + playbackStyle: .image, isEdited: false, ); } diff --git a/mobile/test/unit/factories/partner_user_factory.dart b/mobile/test/unit/factories/partner_user_factory.dart new file mode 100644 index 0000000000..63f94608ad --- /dev/null +++ b/mobile/test/unit/factories/partner_user_factory.dart @@ -0,0 +1,19 @@ +import 'package:immich_mobile/domain/models/user.model.dart'; + +import '../../utils.dart'; + +class PartnerFactory { + const PartnerFactory(); + + static Partner create({String? id, String? email, String? name, bool? inTimeline}) { + id = TestUtils.uuid(id); + return Partner( + id: id, + email: email ?? '$id@test.com', + name: name ?? 'user_$id', + inTimeline: inTimeline ?? false, + hasProfileImage: false, + profileChangedAt: DateTime.now(), + ); + } +} diff --git a/mobile/test/unit/factories/user_factory.dart b/mobile/test/unit/factories/user_factory.dart new file mode 100644 index 0000000000..c89b03abfe --- /dev/null +++ b/mobile/test/unit/factories/user_factory.dart @@ -0,0 +1,26 @@ +import 'package:immich_mobile/domain/models/user.model.dart'; + +import '../../utils.dart'; + +class UserFactory { + const UserFactory(); + + static User create({ + String? id, + String? name, + String? email, + DateTime? profileChangedAt, + bool? hasProfileImage, + AvatarColor? avatarColor, + }) { + id = TestUtils.uuid(id); + return User( + id: id, + name: name ?? 'user_$id', + email: email ?? '$id@test.com', + profileChangedAt: TestUtils.date(profileChangedAt), + hasProfileImage: hasProfileImage ?? false, + avatarColor: avatarColor ?? .primary, + ); + } +} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index b5d91527ea..4f8e608caa 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -5,26 +5,30 @@ import 'package:mocktail/mocktail.dart' as mocktail; import '../domain/service.mock.dart'; import '../infrastructure/repository.mock.dart'; -class UnitMocks { +void _registerFallbacks() { + mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now())); + mocktail.registerFallbackValue( + LocalAsset( + id: '', + name: '', + type: AssetType.image, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, + ), + ); +} + +class RepositoryMocks { final localAlbum = MockLocalAlbumRepository(); final localAsset = MockDriftLocalAssetRepository(); final trashedAsset = MockTrashedLocalAssetRepository(); final nativeApi = MockNativeSyncApi(); - UnitMocks() { - mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now())); - mocktail.registerFallbackValue( - LocalAsset( - id: '', - name: '', - type: AssetType.image, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - playbackStyle: AssetPlaybackStyle.image, - isEdited: false, - ), - ); + RepositoryMocks() { + _registerFallbacks(); } void reset() { @@ -34,3 +38,15 @@ class UnitMocks { mocktail.reset(nativeApi); } } + +class ServiceMocks { + final partner = MockPartnerService(); + + ServiceMocks() { + _registerFallbacks(); + } + + void reset() { + mocktail.reset(partner); + } +} diff --git a/mobile/test/unit/presentation/partner_page_test.dart b/mobile/test/unit/presentation/partner_page_test.dart new file mode 100644 index 0000000000..05caeaf3cc --- /dev/null +++ b/mobile/test/unit/presentation/partner_page_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/pages/library/partner/drift_partner.page.dart'; + +import '../factories/partner_user_factory.dart'; +import '../factories/user_factory.dart'; +import '../presentation_context.dart'; + +void main() { + late PresentationContext context; + + setUp(() async => context = await PresentationContext.create()); + tearDown(() async => await context.dispose()); + + group('PartnerSharedByList', () { + testWidgets('shows the empty-state add button when there are no partners', (tester) async { + await tester.pumpTestWidget( + PartnerSharedByList(partners: const [], onAddPartner: () {}, onRemovePartner: (_) {}), + ); + + expect(find.byType(ListView), findsNothing); + expect(find.widgetWithIcon(ElevatedButton, Icons.person_add), findsOneWidget); + }); + + testWidgets('invokes onAddPartner when the empty-state button is tapped', (tester) async { + var addCalls = 0; + await tester.pumpTestWidget( + PartnerSharedByList(partners: const [], onAddPartner: () => addCalls++, onRemovePartner: (_) {}), + ); + + await tester.tap(find.widgetWithIcon(ElevatedButton, Icons.person_add)); + await tester.pump(); + + expect(addCalls, 1); + }); + + testWidgets('renders a tile per partner with name and email', (tester) async { + final partner1 = PartnerFactory.create(); + final partner2 = PartnerFactory.create(); + await tester.pumpTestWidget( + PartnerSharedByList(partners: [partner1, partner2], onAddPartner: () {}, onRemovePartner: (_) {}), + ); + + expect(find.byType(ListTile), findsNWidgets(2)); + expect(find.text(partner1.name), findsOneWidget); + expect(find.text(partner1.email), findsOneWidget); + expect(find.text(partner2.name), findsOneWidget); + expect(find.text(partner2.email), findsOneWidget); + }); + + testWidgets('invokes onRemovePartner with the tapped partner', (tester) async { + final partner1 = PartnerFactory.create(inTimeline: true); + final partner2 = PartnerFactory.create(); + Partner? removed; + await tester.pumpTestWidget( + PartnerSharedByList(partners: [partner1, partner2], onAddPartner: () {}, onRemovePartner: (p) => removed = p), + ); + + await tester.tap(find.byIcon(Icons.person_remove).first); + await tester.pump(); + + expect(removed, partner1); + }); + }); + + group('PartnerSelectionDialog', () { + final dialogButtonKey = UniqueKey(); + + Widget dialogWidget({void Function(User?)? onClosed}) { + return Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + final selected = await showDialog(context: context, builder: (_) => const PartnerSelectionDialog()); + onClosed?.call(selected); + }, + child: Text(key: dialogButtonKey, 'open'), + ), + ); + } + + List withCandidates(List candidates) => [ + candidatesProvider.overrideWith((ref) => Stream>.value(candidates)), + ]; + + testWidgets('renders an option per candidate fetched from the provider', (tester) async { + final user = UserFactory.create(); + await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user])); + + await tester.tap(find.byKey(dialogButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byType(SimpleDialogOption), findsOneWidget); + expect(find.text(user.name), findsOneWidget); + }); + + testWidgets('shows no options when the provider returns no candidates', (tester) async { + await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const [])); + + await tester.tap(find.byKey(dialogButtonKey)); + await tester.pumpAndSettle(); + + expect(find.byType(SimpleDialogOption), findsNothing); + }); + + testWidgets('pops the selected candidate when an option is tapped', (tester) async { + final user = UserFactory.create(); + User? selected; + await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user])); + + await tester.tap(find.byKey(dialogButtonKey)); + await tester.pumpAndSettle(); + + await tester.tap(find.text(user.name)); + await tester.pumpAndSettle(); + + expect(selected, user); + }); + }); +} diff --git a/mobile/test/unit/presentation_context.dart b/mobile/test/unit/presentation_context.dart new file mode 100644 index 0000000000..97b09ba85e --- /dev/null +++ b/mobile/test/unit/presentation_context.dart @@ -0,0 +1,68 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; + +import '../test_utils.dart'; + +class PresentationContext { + const PresentationContext._(); + + static const String serverEndpoint = 'http://localhost:3000'; + + static Drift? _db; + + static Future create() async { + TestUtils.init(); + if (_db == null) { + final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); + await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint); + _db = db; + } + return const PresentationContext._(); + } + + Future dispose() async { + // TODO: Dispose the store and database after each test. + // This is currently not possible because the store is a singleton and is used across tests. + // Refactor the store to be created per test to allow proper disposal. + } +} + +extension PumpPresentationWidget on WidgetTester { + Future pumpTestWidget(Widget widget, {List overrides = const []}) async { + await pumpWidget( + EasyLocalization( + supportedLocales: locales.values.toList(), + path: translationsPath, + startLocale: locales.values.first, + fallbackLocale: locales.values.first, + saveLocale: false, + useFallbackTranslations: true, + assetLoader: const CodegenLoader(), + child: ProviderScope( + overrides: overrides, + child: Builder( + builder: (context) => MaterialApp( + debugShowCheckedModeBanner: false, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: Material(child: widget), + ), + ), + ), + ), + ); + await pumpAndSettle(); + } +} diff --git a/mobile/test/unit/services/hash_service_test.dart b/mobile/test/unit/services/hash_service_test.dart index 8c4a23c06a..223aaf49af 100644 --- a/mobile/test/unit/services/hash_service_test.dart +++ b/mobile/test/unit/services/hash_service_test.dart @@ -10,7 +10,7 @@ import '../mocks.dart'; void main() { late HashService sut; - final mocks = UnitMocks(); + final mocks = RepositoryMocks(); setUp(() { sut = HashService( diff --git a/mobile/test/unit/utils/editor_test.dart b/mobile/test/unit/utils/editor_test.dart index 16f1c08d05..82cf584f76 100644 --- a/mobile/test/unit/utils/editor_test.dart +++ b/mobile/test/unit/utils/editor_test.dart @@ -43,9 +43,7 @@ void main() { }); test('should handle a single 90° rotation', () { - final edits = [ - RotateEdit(RotateParameters(angle: 90)), - ]; + final edits = [RotateEdit(RotateParameters(angle: 90))]; final result = normalizeTransformEdits(edits); final normalizedEdits = normalizedToEdits(result); @@ -54,9 +52,7 @@ void main() { }); test('should handle a single 180° rotation', () { - final edits = [ - RotateEdit(RotateParameters(angle: 180)), - ]; + final edits = [RotateEdit(RotateParameters(angle: 180))]; final result = normalizeTransformEdits(edits); final normalizedEdits = normalizedToEdits(result); @@ -65,9 +61,7 @@ void main() { }); test('should handle a single 270° rotation', () { - final edits = [ - RotateEdit(RotateParameters(angle: 270)), - ]; + final edits = [RotateEdit(RotateParameters(angle: 270))]; final result = normalizeTransformEdits(edits); final normalizedEdits = normalizedToEdits(result); @@ -76,9 +70,7 @@ void main() { }); test('should handle a single horizontal mirror', () { - final edits = [ - MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)), - ]; + final edits = [MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal))]; final result = normalizeTransformEdits(edits); final normalizedEdits = normalizedToEdits(result); @@ -87,9 +79,7 @@ void main() { }); test('should handle a single vertical mirror', () { - final edits = [ - MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)), - ]; + final edits = [MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical))]; final result = normalizeTransformEdits(edits); final normalizedEdits = normalizedToEdits(result); diff --git a/mobile/test/unit/utils/semver_test.dart b/mobile/test/unit/utils/semver_test.dart index 1e534af593..bbe8c9e7db 100644 --- a/mobile/test/unit/utils/semver_test.dart +++ b/mobile/test/unit/utils/semver_test.dart @@ -16,7 +16,7 @@ void main() { expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException); }); - test('Compares equal versons correctly', () { + test('Compares equal versions correctly', () { final v1 = SemVer.fromString('1.2.3'); final v2 = SemVer.fromString('1.2.3'); expect(v1 == v2, isTrue);