diff --git a/i18n/en.json b/i18n/en.json index 6bcb88d2c5..830b5f915b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1303,9 +1303,9 @@ "login_form_failed_login": "Error logging you in, check server URL, email and password", "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", "login_form_password_hint": "password", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", - "login_has_been_disabled": "Login has been disabled.", + "login_form_server_empty": "Enter a server URL", + "login_form_server_error": "Could not connect to server", + "login_has_been_disabled": "Login has been disabled", "login_password_changed_error": "There was an error updating your password", "login_password_changed_success": "Password updated successfully", "logout_all_device_confirmation": "Are you sure you want to log out all devices?", diff --git a/mobile/lib/pages/library/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart index 0d9e8f95bd..7274b8a14e 100644 --- a/mobile/lib/pages/library/partner/partner.page.dart +++ b/mobile/lib/pages/library/partner/partner.page.dart @@ -1,23 +1,13 @@ 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/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/partner.action.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) { @@ -30,28 +20,6 @@ final partnersStateProvider = StreamProvider.autoDispose>((ref 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}); @@ -65,20 +33,10 @@ class PartnerPage extends ConsumerWidget { 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, - ), - ], + actions: const [ActionIconButtonWidget(action: PartnerAddAction())], ), body: sharedByAsync.when( - data: (partners) => PartnerSharedByList( - partners: partners.toList(growable: false), - onAdd: () => _addPartner(context, ref), - onRemove: (partner) => _removePartner(context, ref, partner), - ), + data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)), loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))), ), @@ -87,9 +45,7 @@ class PartnerPage extends ConsumerWidget { } class _EmptyPartners extends StatelessWidget { - const _EmptyPartners({required this.onAdd}); - - final VoidCallback onAdd; + const _EmptyPartners(); @override Widget build(BuildContext context) { @@ -102,13 +58,9 @@ class _EmptyPartners extends StatelessWidget { padding: const .symmetric(vertical: 8), child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)), ), - Align( + const Align( alignment: .center, - child: ElevatedButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.person_add), - label: Text(context.t.add_partner), - ), + child: ActionButtonWidget(action: PartnerAddAction()), ), ], ), @@ -118,16 +70,14 @@ class _EmptyPartners extends StatelessWidget { @visibleForTesting class PartnerSharedByList extends StatelessWidget { - const PartnerSharedByList({super.key, required this.partners, required this.onAdd, required this.onRemove}); + const PartnerSharedByList({super.key, required this.partners}); final List partners; - final VoidCallback onAdd; - final ValueChanged onRemove; @override Widget build(BuildContext context) { if (partners.isEmpty) { - return _EmptyPartners(onAdd: onAdd); + return const _EmptyPartners(); } return ListView.builder( @@ -138,63 +88,11 @@ class PartnerSharedByList extends StatelessWidget { 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)), + trailing: ActionIconButtonWidget( + action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), + ), ); }, ); } } - -@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/actions/action.dart b/mobile/lib/presentation/actions/action.dart index 419f97e9cb..5d37706aaa 100644 --- a/mobile/lib/presentation/actions/action.dart +++ b/mobile/lib/presentation/actions/action.dart @@ -1,14 +1,23 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; + +class ActionScope { + final BuildContext context; + final WidgetRef ref; + final UserDto authUser; + + const ActionScope({required this.context, required this.ref, required this.authUser}); +} abstract class BaseAction { - final IconData icon; + const BaseAction(); - const BaseAction({required this.icon}); + IconData get icon; - String label(BuildContext context); + String label(ActionScope scope); - bool isVisible(BuildContext context, WidgetRef ref); + bool isVisible(ActionScope scope) => true; - Future onAction(BuildContext context, WidgetRef ref); + Future onAction(ActionScope scope); } diff --git a/mobile/lib/presentation/actions/action.widget.dart b/mobile/lib/presentation/actions/action.widget.dart index 6c0f513808..0f891abde6 100644 --- a/mobile/lib/presentation/actions/action.widget.dart +++ b/mobile/lib/presentation/actions/action.widget.dart @@ -2,30 +2,44 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_ui/immich_ui.dart'; +class _ActionWidgetScope { + final String label; + final VoidCallback onAction; + + const _ActionWidgetScope({required this.label, required this.onAction}); +} + class _ActionWidget extends ConsumerWidget { final BaseAction action; - final Widget Function(Future Function() onAction) builder; + final Widget Function(_ActionWidgetScope context) builder; const _ActionWidget({required this.action, required this.builder}); - Future _onAction(BuildContext context, WidgetRef ref) async { + Future _onAction(ActionScope scope) async { try { - await action.onAction(context, ref); + await action.onAction(scope); } catch (error, stackTrace) { - handleError(context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}'); + handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}'); } } @override Widget build(BuildContext context, WidgetRef ref) { - if (!action.isVisible(context, ref)) { + final authUser = ref.watch(currentUserProvider); + if (authUser == null) { return const SizedBox.shrink(); } - return builder(() => _onAction(context, ref)); + final scope = ActionScope(context: context, ref: ref, authUser: authUser); + if (!action.isVisible(scope)) { + return const SizedBox.shrink(); + } + + return builder(.new(label: action.label(scope), onAction: () => _onAction(scope))); } } @@ -38,7 +52,7 @@ class ActionIconButtonWidget extends StatelessWidget { @override Widget build(BuildContext context) => _ActionWidget( action: action, - builder: (onAction) => ImmichIconButton(icon: action.icon, onPressed: onAction, variant: variant), + builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant), ); } @@ -51,8 +65,8 @@ class ActionButtonWidget extends StatelessWidget { @override Widget build(BuildContext context) => _ActionWidget( action: action, - builder: (onAction) => - ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onAction, variant: variant), + builder: (ctx) => + ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant), ); } @@ -64,7 +78,7 @@ class ActionColumnButtonWidget extends StatelessWidget { @override Widget build(BuildContext context) => _ActionWidget( action: action, - builder: (onAction) => ImmichColumnButton(icon: action.icon, label: action.label(context), onPressed: onAction), + builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction), ); } @@ -76,6 +90,6 @@ class ActionMenuItemWidget extends StatelessWidget { @override Widget build(BuildContext context) => _ActionWidget( action: action, - builder: (onAction) => ImmichMenuItem(icon: action.icon, label: action.label(context), onPressed: onAction), + builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction), ); } diff --git a/mobile/lib/presentation/actions/partner.action.dart b/mobile/lib/presentation/actions/partner.action.dart new file mode 100644 index 0000000000..11fb69ee75 --- /dev/null +++ b/mobile/lib/presentation/actions/partner.action.dart @@ -0,0 +1,125 @@ +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/actions/action.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'; + +class PartnerAddAction extends BaseAction { + const PartnerAddAction(); + + @override + IconData get icon => Icons.person_add_rounded; + + @override + String label(ActionScope scope) => scope.context.t.add_partner; + + @override + Future onAction(ActionScope scope) async { + final ActionScope(:context, :ref, :authUser) = scope; + final selected = await showDialog(context: context, builder: (_) => const PartnerSelectionDialog()); + if (selected == null) { + return; + } + + await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id); + } +} + +class PartnerRemoveAction extends BaseAction { + const PartnerRemoveAction({required this.sharedWithId, required this.partnerName}); + + final String sharedWithId; + final String partnerName; + + @override + IconData get icon => Icons.person_remove_rounded; + + @override + String label(ActionScope scope) => scope.context.t.remove; + + @override + Future onAction(ActionScope scope) async { + final ActionScope(:context, :ref, :authUser) = scope; + + final confirmed = await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: context.t.stop_photo_sharing, + content: context.t.partner_page_stop_sharing_content(partner: partnerName), + ), + ); + if (confirmed != true) { + return; + } + + await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId); + } +} + +@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 +class PartnerSelectionDialog extends ConsumerWidget { + const PartnerSelectionDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final candidatesAsync = ref.watch(candidatesStateProvider); + + return SimpleDialog( + title: Text(context.t.partner_page_select_partner), + children: candidatesAsync.when( + data: (candidates) { + final users = candidates.toList(); + if (users.isEmpty) { + return [ + Padding( + padding: const .symmetric(horizontal: 24, vertical: 8), + child: Text(context.t.partner_page_no_more_users), + ), + ]; + } + 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/widgets/common/confirm_dialog.dart b/mobile/lib/widgets/common/confirm_dialog.dart index 153c124595..9c3f4344c6 100644 --- a/mobile/lib/widgets/common/confirm_dialog.dart +++ b/mobile/lib/widgets/common/confirm_dialog.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; class ConfirmDialog extends StatelessWidget { - final Function onOk; + final Function? onOk; final String title; final String content; final String cancel; @@ -11,7 +11,7 @@ class ConfirmDialog extends StatelessWidget { const ConfirmDialog({ super.key, - required this.onOk, + this.onOk, required this.title, required this.content, this.cancel = "cancel", @@ -21,7 +21,7 @@ class ConfirmDialog extends StatelessWidget { @override Widget build(BuildContext context) { void onOkPressed() { - onOk(); + onOk?.call(); context.pop(true); } diff --git a/mobile/test/domain/service.mock.dart b/mobile/test/domain/service.mock.dart index 743d75f1bf..d5feb56563 100644 --- a/mobile/test/domain/service.mock.dart +++ b/mobile/test/domain/service.mock.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -14,3 +15,5 @@ class MockNativeSyncApi extends Mock implements NativeSyncApi {} class MockAppSettingsService extends Mock implements AppSettingsService {} class MockPartnerService extends Mock implements PartnerService {} + +class MockUserService extends Mock implements UserService {} diff --git a/mobile/test/unit/factories/user_factory.dart b/mobile/test/unit/factories/user_factory.dart index c89b03abfe..248e98dc69 100644 --- a/mobile/test/unit/factories/user_factory.dart +++ b/mobile/test/unit/factories/user_factory.dart @@ -23,4 +23,23 @@ class UserFactory { avatarColor: avatarColor ?? .primary, ); } + + static UserDto createDto({ + String? id, + String? name, + String? email, + DateTime? profileChangedAt, + bool? hasProfileImage, + AvatarColor? avatarColor, + }) { + id = TestUtils.uuid(id); + return UserDto( + 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 4f8e608caa..69260d343d 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -1,24 +1,15 @@ -import 'package:immich_mobile/domain/models/album/local_album.model.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:mocktail/mocktail.dart' as mocktail; +import 'dart:typed_data'; + +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:mocktail/mocktail.dart' as mock; +import 'package:mocktail/mocktail.dart'; import '../domain/service.mock.dart'; import '../infrastructure/repository.mock.dart'; - -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, - ), - ); -} +import 'factories/local_album_factory.dart'; +import 'factories/local_asset_factory.dart'; +import 'factories/user_factory.dart'; class RepositoryMocks { final localAlbum = MockLocalAlbumRepository(); @@ -28,25 +19,103 @@ class RepositoryMocks { final nativeApi = MockNativeSyncApi(); RepositoryMocks() { - _registerFallbacks(); + resetAll(); } - void reset() { - mocktail.reset(localAlbum); - mocktail.reset(localAsset); - mocktail.reset(trashedAsset); - mocktail.reset(nativeApi); + void resetAll() { + _registerFallbacks(); + reset(localAlbum); + reset(localAsset); + reset(trashedAsset); + reset(nativeApi); } } class ServiceMocks { - final partner = MockPartnerService(); + final PartnerStub partner = PartnerStub(MockPartnerService()); + final UserStub user = UserStub(MockUserService()); ServiceMocks() { - _registerFallbacks(); + resetAll(); } - void reset() { - mocktail.reset(partner); + void resetAll() { + _registerFallbacks(); + partner.reset(); + user.reset(); + _stubUserService(); + _stubPartnerService(); + } + + void _stubUserService() { + when(user.getMyUser).thenReturn(UserFactory.createDto()); + when(user.tryGetMyUser).thenReturn(null); + when(user.watchMyUser).thenAnswer((_) => const Stream.empty()); + when(user.refreshMyUser).thenAnswer((_) async => null); + when(user.createProfileImage).thenAnswer((_) async => null); + } + + void _stubPartnerService() { + registerFallbackValue(PartnerDirection.sharedBy); + when(partner.getCandidates).thenAnswer((_) => const Stream.empty()); + when(partner.search).thenAnswer((_) => const Stream.empty()); + when(partner.update).thenAnswer((_) async {}); + when(partner.create).thenAnswer((_) async {}); + when(partner.delete).thenAnswer((_) async {}); } } + +void _registerFallbacks() { + registerFallbackValue(LocalAlbumFactory.create()); + registerFallbackValue(LocalAssetFactory.create()); + registerFallbackValue(Uint8List(0)); +} + +extension type const Stub(T mockedService) { + void reset() => mock.reset(mockedService); +} + +extension type const PartnerStub(MockPartnerService service) implements Stub { + Stream> Function() get getCandidates => + () => service.getCandidates(any()); + + Stream> Function() get search => + () => service.search(any(), any()); + + Future Function() get create => + () => service.create( + sharedById: any(named: 'sharedById'), + sharedWithId: any(named: 'sharedWithId'), + inTimeline: any(named: 'inTimeline'), + ); + + Future Function() get update => + () => service.update( + sharedById: any(named: 'sharedById'), + sharedWithId: any(named: 'sharedWithId'), + inTimeline: any(named: 'inTimeline'), + ); + + Future Function() get delete => + () => service.delete( + sharedById: any(named: 'sharedById'), + sharedWithId: any(named: 'sharedWithId'), + ); +} + +extension type const UserStub(MockUserService service) implements Stub { + UserDto Function() get getMyUser => + () => service.getMyUser(); + + UserDto? Function() get tryGetMyUser => + () => service.tryGetMyUser(); + + Stream Function() get watchMyUser => + () => service.watchMyUser(); + + Future Function() get refreshMyUser => + () => service.refreshMyUser(); + + Future Function() get createProfileImage => + () => service.createProfileImage(any(), any()); +} diff --git a/mobile/test/unit/presentation/actions/partner_action_test.dart b/mobile/test/unit/presentation/actions/partner_action_test.dart new file mode 100644 index 0000000000..1eb89fe4cc --- /dev/null +++ b/mobile/test/unit/presentation/actions/partner_action_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/presentation/actions/partner.action.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../factories/user_factory.dart'; +import '../../mocks.dart'; +import '../../presentation_context.dart'; + +void main() { + late PresentationContext context; + late UserDto currentUser; + final mocks = ServiceMocks(); + + setUp(() async { + currentUser = UserFactory.createDto(); + context = await PresentationContext.create(); + when(mocks.user.tryGetMyUser).thenReturn(currentUser); + }); + + tearDown(() async { + mocks.resetAll(); + await context.dispose(); + }); + + List overrides({List candidates = const []}) => [ + currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)), + partnerServiceProvider.overrideWithValue(mocks.partner.service), + candidatesStateProvider.overrideWith((ref) => Stream>.value(candidates)), + ]; + + group('PartnerAddAction', () { + testWidgets('creates a partner for the selected candidate', (tester) async { + final candidate = UserFactory.create(); + + await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate])); + await tester.pumpUntilFound(find.text(candidate.name)); + await tester.tap(find.text(candidate.name)); + await tester.pumpAndSettle(); + + verify(() => mocks.partner.service.create(sharedById: currentUser.id, sharedWithId: candidate.id)).called(1); + }); + + testWidgets('creates nothing when the selection dialog is dismissed', (tester) async { + await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()])); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting + await tester.pumpAndSettle(); + + verifyNever(mocks.partner.create); + }); + }); + + group('PartnerRemoveAction', () { + testWidgets('deletes the partner after confirmation', (tester) async { + final partner = UserFactory.create(); + await tester.pumpTestAction( + PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), + overrides: overrides(), + ); + await tester.tap(find.byType(TextButton).last); // confirm + await tester.pumpAndSettle(); + + verify(() => mocks.partner.service.delete(sharedById: currentUser.id, sharedWithId: partner.id)).called(1); + }); + + testWidgets('deletes nothing when the confirmation is cancelled', (tester) async { + final partner = UserFactory.create(); + await tester.pumpTestAction( + PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name), + overrides: overrides(), + ); + await tester.tap(find.byType(TextButton).first); // cancel + await tester.pumpAndSettle(); + + verifyNever(mocks.partner.delete); + }); + }); +} diff --git a/mobile/test/unit/presentation/partner_page_test.dart b/mobile/test/unit/presentation/partner_page_test.dart index 957a915ad3..4557388dc0 100644 --- a/mobile/test/unit/presentation/partner_page_test.dart +++ b/mobile/test/unit/presentation/partner_page_test.dart @@ -3,6 +3,7 @@ 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 'package:immich_mobile/presentation/actions/partner.action.dart'; import '../factories/partner_user_factory.dart'; import '../factories/user_factory.dart'; @@ -16,16 +17,18 @@ void main() { group('PartnerSharedByList', () { testWidgets('shows the empty-state add button when there are no partners', (tester) async { - await tester.pumpTestWidget(PartnerSharedByList(partners: const [], onAdd: () {}, onRemove: (_) {})); + final action = const PartnerAddAction(); + + await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides); expect(find.byType(ListView), findsNothing); - expect(find.widgetWithIcon(ElevatedButton, Icons.person_add), findsOneWidget); + expect(find.widgetWithIcon(TextButton, action.icon), 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: (_) {})); + await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides); expect(find.byType(ListTile), findsNWidgets(2)); expect(find.text(partner1.name), findsOneWidget); @@ -34,18 +37,12 @@ void main() { expect(find.text(partner2.email), findsOneWidget); }); - testWidgets('invokes onRemovePartner with the tapped partner', (tester) async { + testWidgets('renders a remove action for each 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); + final action = const PartnerRemoveAction(sharedWithId: '', partnerName: ''); + await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides); + expect(find.byIcon(action.icon), findsNWidgets(2)); }); }); @@ -65,6 +62,7 @@ void main() { } List withCandidates(List candidates) => [ + ...context.overrides, candidatesStateProvider.overrideWith((ref) => Stream>.value(candidates)), ]; diff --git a/mobile/test/unit/presentation_context.dart b/mobile/test/unit/presentation_context.dart index 97b09ba85e..e411b21802 100644 --- a/mobile/test/unit/presentation_context.dart +++ b/mobile/test/unit/presentation_context.dart @@ -6,20 +6,35 @@ 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/models/user.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 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:mocktail/mocktail.dart'; import '../test_utils.dart'; +import 'factories/user_factory.dart'; +import 'mocks.dart'; class PresentationContext { - const PresentationContext._(); + PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() { + when(mocks.user.tryGetMyUser).thenReturn(currentUser); + } static const String serverEndpoint = 'http://localhost:3000'; static Drift? _db; + final UserDto currentUser; + final ServiceMocks mocks; + + List get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))]; + static Future create() async { TestUtils.init(); if (_db == null) { @@ -28,7 +43,7 @@ class PresentationContext { await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint); _db = db; } - return const PresentationContext._(); + return PresentationContext._(user: UserFactory.createDto()); } Future dispose() async { @@ -54,6 +69,7 @@ extension PumpPresentationWidget on WidgetTester { child: Builder( builder: (context) => MaterialApp( debugShowCheckedModeBanner: false, + scaffoldMessengerKey: scaffoldMessengerKey, localizationsDelegates: context.localizationDelegates, supportedLocales: context.supportedLocales, locale: context.locale, @@ -65,4 +81,23 @@ extension PumpPresentationWidget on WidgetTester { ); await pumpAndSettle(); } + + Future pumpTestAction(BaseAction action, {List overrides = const []}) async { + await pumpTestWidget( + Scaffold(body: ActionIconButtonWidget(action: action)), + overrides: overrides, + ); + await tap(find.byType(ImmichIconButton)); + await pump(); + } + + Future pumpUntilFound(Finder finder, {int maxFrames = 10}) async { + for (var i = 0; i < maxFrames; i++) { + await pump(); + if (finder.evaluate().isNotEmpty) { + return; + } + } + throw StateError('pumpUntilFound: $finder not found within $maxFrames frames'); + } } diff --git a/mobile/test/unit/services/hash_service_test.dart b/mobile/test/unit/services/hash_service_test.dart index 223aaf49af..07cf2badf9 100644 --- a/mobile/test/unit/services/hash_service_test.dart +++ b/mobile/test/unit/services/hash_service_test.dart @@ -25,7 +25,7 @@ void main() { }); tearDown(() { - mocks.reset(); + mocks.resetAll(); }); group('HashService', () {