mirror of
https://github.com/immich-app/immich.git
synced 2026-07-14 09:00:59 +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_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_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_password_hint": "password",
|
||||||
"login_form_server_empty": "Enter a server URL.",
|
"login_form_server_empty": "Enter a server URL",
|
||||||
"login_form_server_error": "Could not connect to server.",
|
"login_form_server_error": "Could not connect to server",
|
||||||
"login_has_been_disabled": "Login has been disabled.",
|
"login_has_been_disabled": "Login has been disabled",
|
||||||
"login_password_changed_error": "There was an error updating your password",
|
"login_password_changed_error": "There was an error updating your password",
|
||||||
"login_password_changed_success": "Password updated successfully",
|
"login_password_changed_success": "Password updated successfully",
|
||||||
"logout_all_device_confirmation": "Are you sure you want to log out all devices?",
|
"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:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.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/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/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
|
@visibleForTesting
|
||||||
final partnersStateProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
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);
|
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()
|
@RoutePage()
|
||||||
class PartnerPage extends ConsumerWidget {
|
class PartnerPage extends ConsumerWidget {
|
||||||
const PartnerPage({super.key});
|
const PartnerPage({super.key});
|
||||||
|
|
@ -65,20 +33,10 @@ class PartnerPage extends ConsumerWidget {
|
||||||
title: Text(context.t.partners),
|
title: Text(context.t.partners),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
|
||||||
IconButton(
|
|
||||||
onPressed: () => _addPartner(context, ref),
|
|
||||||
icon: const Icon(Icons.person_add),
|
|
||||||
tooltip: context.t.add_partner,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: sharedByAsync.when(
|
body: sharedByAsync.when(
|
||||||
data: (partners) => PartnerSharedByList(
|
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
|
||||||
partners: partners.toList(growable: false),
|
|
||||||
onAdd: () => _addPartner(context, ref),
|
|
||||||
onRemove: (partner) => _removePartner(context, ref, partner),
|
|
||||||
),
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
|
error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
|
||||||
),
|
),
|
||||||
|
|
@ -87,9 +45,7 @@ class PartnerPage extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyPartners extends StatelessWidget {
|
class _EmptyPartners extends StatelessWidget {
|
||||||
const _EmptyPartners({required this.onAdd});
|
const _EmptyPartners();
|
||||||
|
|
||||||
final VoidCallback onAdd;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -102,13 +58,9 @@ class _EmptyPartners extends StatelessWidget {
|
||||||
padding: const .symmetric(vertical: 8),
|
padding: const .symmetric(vertical: 8),
|
||||||
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
|
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
|
||||||
),
|
),
|
||||||
Align(
|
const Align(
|
||||||
alignment: .center,
|
alignment: .center,
|
||||||
child: ElevatedButton.icon(
|
child: ActionButtonWidget(action: PartnerAddAction()),
|
||||||
onPressed: onAdd,
|
|
||||||
icon: const Icon(Icons.person_add),
|
|
||||||
label: Text(context.t.add_partner),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -118,16 +70,14 @@ class _EmptyPartners extends StatelessWidget {
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
class PartnerSharedByList extends StatelessWidget {
|
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 List<Partner> partners;
|
||||||
final VoidCallback onAdd;
|
|
||||||
final ValueChanged<Partner> onRemove;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (partners.isEmpty) {
|
if (partners.isEmpty) {
|
||||||
return _EmptyPartners(onAdd: onAdd);
|
return const _EmptyPartners();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
|
@ -138,63 +88,11 @@ class PartnerSharedByList extends StatelessWidget {
|
||||||
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
|
||||||
title: Text(partner.name),
|
title: Text(partner.name),
|
||||||
subtitle: Text(partner.email),
|
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:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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 {
|
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:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.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_mobile/utils/error_handler.dart';
|
||||||
import 'package:immich_ui/immich_ui.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 {
|
class _ActionWidget extends ConsumerWidget {
|
||||||
final BaseAction action;
|
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});
|
const _ActionWidget({required this.action, required this.builder});
|
||||||
|
|
||||||
Future<void> _onAction(BuildContext context, WidgetRef ref) async {
|
Future<void> _onAction(ActionScope scope) async {
|
||||||
try {
|
try {
|
||||||
await action.onAction(context, ref);
|
await action.onAction(scope);
|
||||||
} catch (error, stackTrace) {
|
} 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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (!action.isVisible(context, ref)) {
|
final authUser = ref.watch(currentUserProvider);
|
||||||
|
if (authUser == null) {
|
||||||
return const SizedBox.shrink();
|
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
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
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
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (onAction) =>
|
builder: (ctx) =>
|
||||||
ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onAction, variant: variant),
|
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +78,7 @@ class ActionColumnButtonWidget extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
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
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
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';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
class ConfirmDialog extends StatelessWidget {
|
class ConfirmDialog extends StatelessWidget {
|
||||||
final Function onOk;
|
final Function? onOk;
|
||||||
final String title;
|
final String title;
|
||||||
final String content;
|
final String content;
|
||||||
final String cancel;
|
final String cancel;
|
||||||
|
|
@ -11,7 +11,7 @@ class ConfirmDialog extends StatelessWidget {
|
||||||
|
|
||||||
const ConfirmDialog({
|
const ConfirmDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onOk,
|
this.onOk,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.content,
|
required this.content,
|
||||||
this.cancel = "cancel",
|
this.cancel = "cancel",
|
||||||
|
|
@ -21,7 +21,7 @@ class ConfirmDialog extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
void onOkPressed() {
|
void onOkPressed() {
|
||||||
onOk();
|
onOk?.call();
|
||||||
context.pop(true);
|
context.pop(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:immich_mobile/domain/services/partner.service.dart';
|
import 'package:immich_mobile/domain/services/partner.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.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/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.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 MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
class MockPartnerService extends Mock implements PartnerService {}
|
class MockPartnerService extends Mock implements PartnerService {}
|
||||||
|
|
||||||
|
class MockUserService extends Mock implements UserService {}
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,23 @@ class UserFactory {
|
||||||
avatarColor: avatarColor ?? .primary,
|
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 'dart:typed_data';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart' as mocktail;
|
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 '../domain/service.mock.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
|
import 'factories/local_album_factory.dart';
|
||||||
void _registerFallbacks() {
|
import 'factories/local_asset_factory.dart';
|
||||||
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
|
import 'factories/user_factory.dart';
|
||||||
mocktail.registerFallbackValue(
|
|
||||||
LocalAsset(
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
type: AssetType.image,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
playbackStyle: AssetPlaybackStyle.image,
|
|
||||||
isEdited: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RepositoryMocks {
|
class RepositoryMocks {
|
||||||
final localAlbum = MockLocalAlbumRepository();
|
final localAlbum = MockLocalAlbumRepository();
|
||||||
|
|
@ -28,25 +19,103 @@ class RepositoryMocks {
|
||||||
final nativeApi = MockNativeSyncApi();
|
final nativeApi = MockNativeSyncApi();
|
||||||
|
|
||||||
RepositoryMocks() {
|
RepositoryMocks() {
|
||||||
_registerFallbacks();
|
resetAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void resetAll() {
|
||||||
mocktail.reset(localAlbum);
|
_registerFallbacks();
|
||||||
mocktail.reset(localAsset);
|
reset(localAlbum);
|
||||||
mocktail.reset(trashedAsset);
|
reset(localAsset);
|
||||||
mocktail.reset(nativeApi);
|
reset(trashedAsset);
|
||||||
|
reset(nativeApi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServiceMocks {
|
class ServiceMocks {
|
||||||
final partner = MockPartnerService();
|
final PartnerStub partner = PartnerStub(MockPartnerService());
|
||||||
|
final UserStub user = UserStub(MockUserService());
|
||||||
|
|
||||||
ServiceMocks() {
|
ServiceMocks() {
|
||||||
_registerFallbacks();
|
resetAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void resetAll() {
|
||||||
mocktail.reset(partner);
|
_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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/pages/library/partner/partner.page.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/partner_user_factory.dart';
|
||||||
import '../factories/user_factory.dart';
|
import '../factories/user_factory.dart';
|
||||||
|
|
@ -16,16 +17,18 @@ void main() {
|
||||||
|
|
||||||
group('PartnerSharedByList', () {
|
group('PartnerSharedByList', () {
|
||||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
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.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 {
|
testWidgets('renders a tile per partner with name and email', (tester) async {
|
||||||
final partner1 = PartnerFactory.create();
|
final partner1 = PartnerFactory.create();
|
||||||
final partner2 = 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.byType(ListTile), findsNWidgets(2));
|
||||||
expect(find.text(partner1.name), findsOneWidget);
|
expect(find.text(partner1.name), findsOneWidget);
|
||||||
|
|
@ -34,18 +37,12 @@ void main() {
|
||||||
expect(find.text(partner2.email), findsOneWidget);
|
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 partner1 = PartnerFactory.create(inTimeline: true);
|
||||||
final partner2 = PartnerFactory.create();
|
final partner2 = PartnerFactory.create();
|
||||||
Partner? removed;
|
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||||
await tester.pumpTestWidget(
|
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
|
||||||
PartnerSharedByList(partners: [partner1, partner2], onAdd: () {}, onRemove: (p) => removed = p),
|
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.person_remove).first);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(removed, partner1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -65,6 +62,7 @@ void main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Override> withCandidates(List<User> candidates) => [
|
List<Override> withCandidates(List<User> candidates) => [
|
||||||
|
...context.overrides,
|
||||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/locales.dart';
|
import 'package:immich_mobile/constants/locales.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.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/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/generated/codegen_loader.g.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/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.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 '../test_utils.dart';
|
||||||
|
import 'factories/user_factory.dart';
|
||||||
|
import 'mocks.dart';
|
||||||
|
|
||||||
class PresentationContext {
|
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 const String serverEndpoint = 'http://localhost:3000';
|
||||||
|
|
||||||
static Drift? _db;
|
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 {
|
static Future<PresentationContext> create() async {
|
||||||
TestUtils.init();
|
TestUtils.init();
|
||||||
if (_db == null) {
|
if (_db == null) {
|
||||||
|
|
@ -28,7 +43,7 @@ class PresentationContext {
|
||||||
await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint);
|
await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint);
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
return const PresentationContext._();
|
return PresentationContext._(user: UserFactory.createDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
|
@ -54,6 +69,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) => MaterialApp(
|
builder: (context) => MaterialApp(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
localizationsDelegates: context.localizationDelegates,
|
localizationsDelegates: context.localizationDelegates,
|
||||||
supportedLocales: context.supportedLocales,
|
supportedLocales: context.supportedLocales,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
|
|
@ -65,4 +81,23 @@ extension PumpPresentationWidget on WidgetTester {
|
||||||
);
|
);
|
||||||
await pumpAndSettle();
|
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(() {
|
tearDown(() {
|
||||||
mocks.reset();
|
mocks.resetAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HashService', () {
|
group('HashService', () {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue