mirror of
https://github.com/immich-app/immich.git
synced 2026-07-05 20:57:40 +00:00
feat: partner actions (#29281)
* feat: partner actions # Conflicts: # i18n/en.json * cleanup * fix tests * ci fix --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
parent
5165cf1d2f
commit
f29f86542c
13 changed files with 433 additions and 180 deletions
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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<Iterable<User>>((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<Iterable<Partner>>((ref) {
|
||||
|
|
@ -30,28 +20,6 @@ final partnersStateProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref
|
|||
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedBy);
|
||||
});
|
||||
|
||||
Future<void> _addPartner(BuildContext context, WidgetRef ref) async {
|
||||
final selected = await showDialog<User>(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<void> _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<Partner> partners;
|
||||
final VoidCallback onAdd;
|
||||
final ValueChanged<Partner> 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> onAction(BuildContext context, WidgetRef ref);
|
||||
Future<void> onAction(ActionScope scope);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> Function() onAction) builder;
|
||||
final Widget Function(_ActionWidgetScope context) builder;
|
||||
|
||||
const _ActionWidget({required this.action, required this.builder});
|
||||
|
||||
Future<void> _onAction(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
125
mobile/lib/presentation/actions/partner.action.dart
Normal file
125
mobile/lib/presentation/actions/partner.action.dart
Normal file
|
|
@ -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<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:context, :ref, :authUser) = scope;
|
||||
final selected = await showDialog<User>(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<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:context, :ref, :authUser) = scope;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<Iterable<User>>((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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 extends Mock>(T mockedService) {
|
||||
void reset() => mock.reset(mockedService);
|
||||
}
|
||||
|
||||
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
Stream<Iterable<User>> Function() get getCandidates =>
|
||||
() => service.getCandidates(any());
|
||||
|
||||
Stream<Iterable<Partner>> Function() get search =>
|
||||
() => service.search(any(), any());
|
||||
|
||||
Future<void> Function() get create =>
|
||||
() => service.create(
|
||||
sharedById: any(named: 'sharedById'),
|
||||
sharedWithId: any(named: 'sharedWithId'),
|
||||
inTimeline: any(named: 'inTimeline'),
|
||||
);
|
||||
|
||||
Future<void> Function() get update =>
|
||||
() => service.update(
|
||||
sharedById: any(named: 'sharedById'),
|
||||
sharedWithId: any(named: 'sharedWithId'),
|
||||
inTimeline: any(named: 'inTimeline'),
|
||||
);
|
||||
|
||||
Future<void> Function() get delete =>
|
||||
() => service.delete(
|
||||
sharedById: any(named: 'sharedById'),
|
||||
sharedWithId: any(named: 'sharedWithId'),
|
||||
);
|
||||
}
|
||||
|
||||
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
|
||||
UserDto Function() get getMyUser =>
|
||||
() => service.getMyUser();
|
||||
|
||||
UserDto? Function() get tryGetMyUser =>
|
||||
() => service.tryGetMyUser();
|
||||
|
||||
Stream<UserDto?> Function() get watchMyUser =>
|
||||
() => service.watchMyUser();
|
||||
|
||||
Future<UserDto?> Function() get refreshMyUser =>
|
||||
() => service.refreshMyUser();
|
||||
|
||||
Future<String?> Function() get createProfileImage =>
|
||||
() => service.createProfileImage(any(), any());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Override> overrides({List<User> candidates = const []}) => [
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)),
|
||||
partnerServiceProvider.overrideWithValue(mocks.partner.service),
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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<Override> withCandidates(List<User> candidates) => [
|
||||
...context.overrides,
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
|
||||
|
||||
static Future<PresentationContext> 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<void> 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<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||
await pumpTestWidget(
|
||||
Scaffold(body: ActionIconButtonWidget(action: action)),
|
||||
overrides: overrides,
|
||||
);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
|
||||
Future<void> 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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ void main() {
|
|||
});
|
||||
|
||||
tearDown(() {
|
||||
mocks.reset();
|
||||
mocks.resetAll();
|
||||
});
|
||||
|
||||
group('HashService', () {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue