Compare commits

...

2 Commits

Author SHA1 Message Date
shenlong-tanwen 025e71cfef feat: partner actions 2026-06-23 16:05:30 +05:30
shenlong-tanwen d56ee21e8c feat: mobile actions 2026-06-23 15:17:57 +05:30
14 changed files with 535 additions and 162 deletions
+3 -3
View File
@@ -1491,9 +1491,9 @@
"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_save_login": "Stay logged in",
"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)),
),
],
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
abstract class BaseAction {
final IconData icon;
const BaseAction({required this.icon});
String label(BuildContext context);
bool isVisible(BuildContext context, WidgetRef ref);
Future<void> onAction(BuildContext context, WidgetRef ref);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final List<T> assets;
const AssetAction({required super.icon, required this.assets});
List<T> assetsForAction(BuildContext context, WidgetRef ref);
}
@@ -0,0 +1,72 @@
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/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
abstract class BaseActionWidget extends ConsumerWidget {
final BaseAction action;
final void Function(BuildContext _, WidgetRef _)? postAction;
const BaseActionWidget({super.key, required this.action, this.postAction});
Widget buildAction(BuildContext context, Future<void> Function() onPressed);
Future<void> _onPressed(BuildContext context, WidgetRef ref) async {
try {
await action.onAction(context, ref);
} catch (error, stackTrace) {
handleError(context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
if (context.mounted) {
postAction?.call(context, ref);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!action.isVisible(context, ref)) {
return const SizedBox.shrink();
}
return buildAction(context, () => _onPressed(context, ref));
}
}
class ActionIconButtonWidget extends BaseActionWidget {
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichIconButton(icon: action.icon, onPressed: onPressed, variant: variant);
}
class ActionButtonWidget extends BaseActionWidget {
final ImmichVariant variant;
const ActionButtonWidget({super.key, required super.action, this.variant = .ghost, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichTextButton(labelText: action.label(context), icon: action.icon, onPressed: onPressed, variant: variant);
}
class ActionColumnButtonWidget extends BaseActionWidget {
const ActionColumnButtonWidget({super.key, required super.action, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichColumnButton(icon: action.icon, label: action.label(context), onPressed: onPressed);
}
class ActionMenuItemWidget extends BaseActionWidget {
const ActionMenuItemWidget({super.key, required super.action, super.postAction});
@override
Widget buildAction(BuildContext context, Future<void> Function() onPressed) =>
ImmichMenuItem(icon: action.icon, label: action.label(context), onPressed: onPressed);
}
@@ -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() : super(icon: Icons.person_add_rounded);
@override
String label(BuildContext context) => context.t.add_partner;
@override
bool isVisible(BuildContext context, WidgetRef ref) => true;
@override
Future<void> onAction(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) {
return;
}
await ref.read(partnerServiceProvider).create(sharedById: currentUser.id, sharedWithId: selected.id);
}
}
class PartnerRemoveAction extends BaseAction {
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName})
: super(icon: Icons.person_remove_rounded);
final String sharedWithId;
final String partnerName;
@override
String label(BuildContext context) => context.t.remove;
@override
bool isVisible(BuildContext context, WidgetRef ref) => true;
@override
Future<void> onAction(BuildContext context, WidgetRef ref) async {
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),
),
);
final currentUser = ref.read(currentUserProvider);
if (confirmed != true || currentUser == null) {
return;
}
await ref.read(partnerServiceProvider).delete(sharedById: currentUser.id, sharedWithId: sharedWithId);
}
}
@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)),
),
],
),
);
}
}
@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);
});
+61
View File
@@ -0,0 +1,61 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
final clean = trace.foldFrames(
(frame) => frame.package == 'flutter' || frame.package == 'flutter_test' || frame.isCore,
terse: true,
);
stackTrace = clean.toString();
}
dPrint(
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
}
snackbar.error(message);
}
@visibleForTesting
String? serverErrorMessage(Object error) {
if (error is! ApiException || error.innerException != null || error.message == null) {
return null;
}
try {
final body = jsonDecode(error.message!);
if (body is Map && body['message'] != null) {
final message = body['message'];
return message is List ? message.join(', ') : message.toString();
}
} catch (_) {
// The body was not JSON; fall back to the raw payload below.
}
return error.message;
}
@visibleForTesting
bool isConnectionError(Object error) => error is ApiException && error.innerException != null;
@@ -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);
}
+3
View File
@@ -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,
);
}
}
+97 -28
View File
@@ -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 partner = PartnerStub(MockPartnerService());
final 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: []));
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]));
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]));
expect(find.byIcon(action.icon), findsNWidgets(2));
});
});
@@ -10,6 +10,9 @@ 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_ui/immich_ui.dart';
import '../test_utils.dart';
@@ -54,6 +57,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 +69,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', () {