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..d1a7fc5546 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -237,3 +237,125 @@ 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, + }); + + Partner.fromUser(User user, {this.inTimeline = false}) + : super( + id: user.id, + name: user.name, + email: user.email, + profileChangedAt: user.profileChangedAt, + hasProfileImage: user.hasProfileImage, + avatarColor: user.avatarColor, + ); + + @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..63985823aa 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({required String sharedById, required String sharedWithId, required bool inTimeline}) async { + await _partnerApiRepository.update(sharedById, inTimeline: inTimeline); + await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, 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 create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async { + await _partnerApiRepository.create(sharedWithId); + await _partnerRepository.create(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline); } - 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 { - await _partnerApiRepository.create(partnerId); - await _driftPartnerRepository.create(partnerId, userId); - } - - Future removePartner(String partnerId, String userId) async { - await _partnerApiRepository.delete(partnerId); - await _driftPartnerRepository.delete(partnerId, userId); + Future delete({required String sharedById, required String sharedWithId}) async { + await _partnerApiRepository.delete(sharedWithId); + await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId); } } diff --git a/mobile/lib/infrastructure/mapper.dart b/mobile/lib/infrastructure/mapper.dart new file mode 100644 index 0000000000..a53aa2419a --- /dev/null +++ b/mobile/lib/infrastructure/mapper.dart @@ -0,0 +1,15 @@ +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.drift.dart'; + +User mapToUser(UserEntityData data) => User( + id: data.id, + name: data.name, + email: data.email, + hasProfileImage: data.hasProfileImage, + profileChangedAt: data.profileChangedAt, + avatarColor: data.avatarColor, +); + +Partner mapToPartner(UserEntityData user, PartnerEntityData partner) => + Partner.fromUser(mapToUser(user), inTimeline: partner.inTimeline); diff --git a/mobile/lib/infrastructure/repositories/partner.repository.dart b/mobile/lib/infrastructure/repositories/partner.repository.dart index b12061ad24..ee18c84b4e 100644 --- a/mobile/lib/infrastructure/repositories/partner.repository.dart +++ b/mobile/lib/infrastructure/repositories/partner.repository.dart @@ -1,106 +1,62 @@ 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.drift.dart'; +import 'package:immich_mobile/infrastructure/mapper.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)); + Future get({required String sharedById, required String sharedWithId}) => + (_db.select(_db.partnerEntity).join([ + innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)), + ])..where( + _db.partnerEntity.sharedById.equals(sharedById) & _db.partnerEntity.sharedWithId.equals(sharedWithId), + )) + .map(_resultToPartner) + .getSingle(); - 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(); - } + 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 we can share our library with - Future> getAvailablePartners(String currentUserId) { - final query = _db.select(_db.userEntity)..where((row) => row.id.equals(currentUserId).not()); + Future create({required String sharedById, required String sharedWithId, bool inTimeline = false}) => + _db.partnerEntity.insertOnConflictUpdate( + PartnerEntityCompanion( + sharedById: Value(sharedById), + sharedWithId: Value(sharedWithId), + inTimeline: Value(inTimeline), + ), + ); - return query.map((user) { - return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false); - }).get(); - } + Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) => + (_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId))) + .write(PartnerEntityCompanion(inTimeline: Value(inTimeline))); - // 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 delete({required String sharedById, required String sharedWithId}) => + (_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId))) + .go(); - 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(); - } - - // 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)); + Partner _resultToPartner(TypedResult result) { + final user = result.readTable(_db.userEntity); + final partner = result.readTable(_db.partnerEntity); + return mapToPartner(user, partner); } } diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index afcf2271dd..6df7344991 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/mapper.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(mapToUser).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 deleted file mode 100644 index a24323c02a..0000000000 --- a/mobile/lib/pages/library/partner/drift_partner.page.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -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/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/widgets/common/confirm_dialog.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -@RoutePage() -class DriftPartnerPage extends HookConsumerWidget { - const DriftPartnerPage({super.key}); - - @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), - ); - }, - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text("partners").t(context: context), - elevation: 0, - centerTitle: false, - actions: [ - IconButton( - onPressed: potentialPartnersAsync.whenOrNull(data: (data) => addNewUsersHandler), - icon: const Icon(Icons.person_add), - tooltip: "add_partner".tr(), - ), - ], - ), - body: _SharedToPartnerList(onAddPartner: addNewUsersHandler, onDeletePartner: onDeleteUser), - ); - } -} - -class _SharedToPartnerList extends ConsumerWidget { - final VoidCallback onAddPartner; - final Function(PartnerUserDto partner) onDeletePartner; - - const _SharedToPartnerList({required this.onAddPartner, required this.onDeletePartner}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final partnerAsync = ref.watch(driftSharedByPartnerProvider); - - 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(), - ), - 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()]))), - ); - } -} diff --git a/mobile/lib/pages/library/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart new file mode 100644 index 0000000000..0d9e8f95bd --- /dev/null +++ b/mobile/lib/pages/library/partner/partner.page.dart @@ -0,0 +1,200 @@ +import 'package:auto_route/auto_route.dart'; +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/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.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'; + +@visibleForTesting +final candidatesStateProvider = 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 partnersStateProvider = 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); +}); + +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(sharedById: currentUser.id, sharedWithId: selected.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(sharedById: currentUser.id, sharedWithId: partner.id); + } + }, + ), +); + +@RoutePage() +class PartnerPage extends ConsumerWidget { + const PartnerPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sharedByAsync = ref.watch(partnersStateProvider); + + return Scaffold( + appBar: AppBar( + title: Text(context.t.partners), + elevation: 0, + centerTitle: false, + actions: [ + IconButton( + onPressed: () => _addPartner(context, ref), + icon: const Icon(Icons.person_add), + tooltip: context.t.add_partner, + ), + ], + ), + body: sharedByAsync.when( + data: (partners) => PartnerSharedByList( + partners: partners.toList(growable: false), + onAdd: () => _addPartner(context, ref), + onRemove: (partner) => _removePartner(context, ref, partner), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))), + ), + ); + } +} + +class _EmptyPartners extends StatelessWidget { + const _EmptyPartners({required this.onAdd}); + + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const .symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: .start, + children: [ + Padding( + padding: const .symmetric(vertical: 8), + child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)), + ), + Align( + alignment: .center, + child: ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add), + label: Text(context.t.add_partner), + ), + ), + ], + ), + ); + } +} + +@visibleForTesting +class PartnerSharedByList extends StatelessWidget { + const PartnerSharedByList({super.key, required this.partners, required this.onAdd, required this.onRemove}); + + final List partners; + final VoidCallback onAdd; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + if (partners.isEmpty) { + return _EmptyPartners(onAdd: onAdd); + } + + 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: () => onRemove(partner)), + ); + }, + ); + } +} + +@visibleForTesting +class PartnerSelectionDialog extends ConsumerWidget { + const PartnerSelectionDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final candidatesAsync = ref.watch(candidatesStateProvider); + + 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 .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 .only(right: 8), + child: PartnerUserAvatar(userId: candidate.id, name: candidate.name), + ), + Text(candidate.name), + ], + ), + ), + ]; + }, + loading: () => const [ + Padding( + padding: .all(24), + child: Center(child: CircularProgressIndicator()), + ), + ], + error: (error, _) => [ + Padding( + padding: const .symmetric(horizontal: 24, vertical: 8), + child: Text(context.t.error_loading_partners(error: error)), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 4708b5e615..673df089d5 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,12 +328,23 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { } } +@visibleForTesting +final sharedWithPartnerProvider = 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 .empty(); + } + + return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith); +}); + class _QuickAccessButtonList extends ConsumerWidget { const _QuickAccessButtonList(); @override Widget build(BuildContext context, WidgetRef ref) { - final partnerSharedWithAsync = ref.watch(driftSharedWithPartnerProvider); + final partnerSharedWithAsync = ref.watch(sharedWithPartnerProvider); final partners = partnerSharedWithAsync.valueOrNull ?? []; return SliverPadding( @@ -387,9 +399,9 @@ class _QuickAccessButtonList extends ConsumerWidget { 'partners'.t(context: context), style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500), ), - onTap: () => context.pushRoute(const DriftPartnerRoute()), + onTap: () => context.pushRoute(const PartnerRoute()), ), - _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..7df96cf78e 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,9 @@ class _InfoBoxState extends ConsumerState<_InfoBox> { } try { - await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline(widget.partner.id, user.id); + await ref + .read(partnerServiceProvider) + .update(sharedById: widget.partner.id, sharedWithId: 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..a5403e3966 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -27,7 +27,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_shell.page.dart'; import 'package:immich_mobile/pages/library/folder/folder.page.dart'; import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart'; -import 'package:immich_mobile/pages/library/partner/drift_partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.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'; @@ -176,7 +176,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftPlaceRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPlaceDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftUserSelectionRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: SyncStatusRoute.page, guards: [_duplicateGuard]), AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4b538d789..bc7226c2ee 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() { @@ -869,22 +869,6 @@ class DriftPartnerDetailRouteArgs { int get hashCode => key.hashCode ^ partner.hashCode; } -/// generated route for -/// [DriftPartnerPage] -class DriftPartnerRoute extends PageRouteInfo { - const DriftPartnerRoute({List? children}) - : super(DriftPartnerRoute.name, initialChildren: children); - - static const String name = 'DriftPartnerRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const DriftPartnerPage(); - }, - ); -} - /// generated route for /// [DriftPeopleCollectionPage] class DriftPeopleCollectionRoute extends PageRouteInfo { @@ -1456,6 +1440,22 @@ class MapLocationPickerRouteArgs { int get hashCode => key.hashCode ^ initialLatLng.hashCode; } +/// generated route for +/// [PartnerPage] +class PartnerRoute extends PageRouteInfo { + const PartnerRoute({List? children}) + : super(PartnerRoute.name, initialChildren: children); + + static const String name = 'PartnerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PartnerPage(); + }, + ); +} + /// generated route for /// [PinAuthPage] class PinAuthRoute extends PageRouteInfo { 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..298b8b852d --- /dev/null +++ b/mobile/test/medium/repositories/partner_repository_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.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, .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, .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, .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(sharedById: me.id, sharedWithId: partner.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(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true); + + final result = await sut.get(sharedById: sharer.id, sharedWithId: 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(sharedById: me.id, sharedWithId: recipient.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..4c31c9212e --- /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(sharedById: me.id, sharedWithId: partner.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(sharedById: me.id, sharedWithId: recipient.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(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true); + + verify(() => ctx.partnerApi.update(sharer.id, inTimeline: true)).called(1); + final partner = await ctx.partnerRepository.get(sharedById: sharer.id, sharedWithId: 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..957a915ad3 --- /dev/null +++ b/mobile/test/unit/presentation/partner_page_test.dart @@ -0,0 +1,105 @@ +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/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 [], onAdd: () {}, onRemove: (_) {})); + + expect(find.byType(ListView), findsNothing); + expect(find.widgetWithIcon(ElevatedButton, Icons.person_add), findsOneWidget); + }); + + 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], onAdd: () {}, onRemove: (_) {})); + + 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], onAdd: () {}, onRemove: (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) => [ + candidatesStateProvider.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);