mirror of
https://github.com/immich-app/immich.git
synced 2026-06-27 17:03:22 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d6e9d1688 | |||
| 4555888806 | |||
| d490c68a9d | |||
| b2e3702cf4 |
@@ -1,5 +1,6 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
@@ -77,4 +78,12 @@ class AssetService {
|
||||
await _apiRepository.updateFavorite(remoteIds, isFavorite);
|
||||
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
|
||||
}
|
||||
|
||||
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
|
||||
if (edits.isEmpty) {
|
||||
await _apiRepository.removeEdits(remoteId);
|
||||
} else {
|
||||
await _apiRepository.editAsset(remoteId, edits);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class EditAssetAction extends AssetAction<RemoteAsset> {
|
||||
const EditAssetAction({required super.assets});
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.tune;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.edit;
|
||||
|
||||
@override
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) =>
|
||||
assets.where((asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isEditable).cast();
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) =>
|
||||
filter(scope).length == 1 &&
|
||||
scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
|
||||
final asset = filter(scope).first;
|
||||
final remoteId = asset.id;
|
||||
final repository = ref.read(remoteAssetRepositoryProvider);
|
||||
final (edits, exif) = await (repository.getAssetEdits(remoteId), repository.getExif(remoteId)).wait;
|
||||
if (exif == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exif);
|
||||
unawaited(
|
||||
context.pushRoute(
|
||||
DriftEditImageRoute(
|
||||
image: Image(image: getFullImageProvider(asset, edited: false)),
|
||||
applyEdits: (newEdits) => applyEdits(ref, remoteId, newEdits),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> applyEdits(WidgetRef ref, String remoteId, List<AssetEdit> edits) async {
|
||||
final websocket = ref.read(websocketProvider.notifier);
|
||||
|
||||
bool isCurrentId(dynamic data) => data is Map && (data['asset'] as Map?)?['id'] == remoteId;
|
||||
await ref.read(assetServiceProvider).applyEdits(remoteId, edits);
|
||||
await Future.any([
|
||||
websocket.waitForEvent('AssetEditReadyV1', isCurrentId, const Duration(seconds: 10)),
|
||||
websocket.waitForEvent('AssetEditReadyV2', isCurrentId, const Duration(seconds: 10)),
|
||||
]).catchError((_) {});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
.where(
|
||||
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
||||
)
|
||||
.cast<RemoteAsset>();
|
||||
.cast();
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
const EditImageActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
|
||||
Future<void> editImage(List<AssetEdit> edits) async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
|
||||
}
|
||||
|
||||
Future<void> onPress() async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageProvider = getFullImageProvider(currentAsset, edited: false);
|
||||
|
||||
final image = Image(image: imageProvider);
|
||||
final (edits, exifInfo) = await (
|
||||
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
|
||||
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
|
||||
).wait;
|
||||
|
||||
if (exifInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
|
||||
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
|
||||
}
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.tune,
|
||||
label: "edit".t(context: context),
|
||||
onPressed: onPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
@@ -17,10 +18,9 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ViewerBottomBar extends ConsumerWidget {
|
||||
const ViewerBottomBar({super.key});
|
||||
@@ -37,10 +37,10 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final actionAsset = [asset];
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
@@ -51,9 +51,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
ActionColumnButtonWidget(action: EditAssetAction(assets: actionAsset)),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (isOwner) ...[
|
||||
@@ -104,7 +102,10 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
OcrToggleButton(asset: asset),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
ImmichColorOverride(
|
||||
color: Colors.white,
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
@@ -17,18 +15,13 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
|
||||
|
||||
@@ -135,16 +128,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
};
|
||||
}
|
||||
|
||||
Future<ActionResult> troubleshoot(ActionSource source, BuildContext context) async {
|
||||
final assets = _getAssets(source);
|
||||
if (assets.length > 1) {
|
||||
return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets');
|
||||
}
|
||||
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||
|
||||
return ActionResult(count: assets.length, success: true);
|
||||
}
|
||||
|
||||
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
@@ -631,37 +614,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
|
||||
if (ids.length != 1) {
|
||||
_logger.warning('applyEdits called with multiple assets, expected single asset');
|
||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
|
||||
}
|
||||
|
||||
Future<void> editReady;
|
||||
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
|
||||
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
} else {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
}
|
||||
|
||||
try {
|
||||
await _service.applyEdits(ids.first, edits);
|
||||
await editReady;
|
||||
return const ActionResult(count: 1, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to apply edits to assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on Iterable<RemoteAsset> {
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -305,14 +304,6 @@ class ActionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
|
||||
if (edits.isEmpty) {
|
||||
await _assetApiRepository.removeEdits(remoteId);
|
||||
} else {
|
||||
await _assetApiRepository.editAsset(remoteId, edits);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (deletedIds.isEmpty) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockStoreService extends Mock implements StoreService {}
|
||||
@@ -20,3 +21,5 @@ class MockPartnerService extends Mock implements PartnerService {}
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
class MockServerInfoService extends Mock implements ServerInfoService {}
|
||||
|
||||
@@ -5,7 +5,13 @@ import '../../utils.dart';
|
||||
class RemoteAssetFactory {
|
||||
const RemoteAssetFactory();
|
||||
|
||||
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
|
||||
static RemoteAsset create({
|
||||
String? id,
|
||||
String? name,
|
||||
String? ownerId,
|
||||
bool isFavorite = false,
|
||||
AssetType type = .image,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return RemoteAsset(
|
||||
@@ -13,7 +19,7 @@ class RemoteAssetFactory {
|
||||
name: name ?? 'remote_$id.jpg',
|
||||
ownerId: TestUtils.uuid(ownerId),
|
||||
checksum: 'checksum-$id',
|
||||
type: .image,
|
||||
type: type,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isFavorite: isFavorite,
|
||||
|
||||
+80
-14
@@ -1,7 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart' as mock;
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -12,11 +17,12 @@ import 'factories/local_asset_factory.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
|
||||
class RepositoryMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository());
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
|
||||
RepositoryMocks() {
|
||||
resetAll();
|
||||
@@ -24,17 +30,41 @@ class RepositoryMocks {
|
||||
|
||||
void resetAll() {
|
||||
_registerFallbacks();
|
||||
reset(localAlbum);
|
||||
reset(localAsset);
|
||||
localAlbum.reset();
|
||||
localAsset.reset();
|
||||
reset(trashedAsset);
|
||||
reset(nativeApi);
|
||||
remoteAsset.reset();
|
||||
nativeApi.reset();
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
_stubNativeSyncApi();
|
||||
_stubRemoteAssetRepository();
|
||||
}
|
||||
|
||||
void _stubLocalAlbumRepository() {
|
||||
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
|
||||
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
|
||||
}
|
||||
|
||||
void _stubLocalAssetRepository() {
|
||||
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
|
||||
when(localAsset.updateHashes).thenAnswer((_) async => {});
|
||||
}
|
||||
|
||||
void _stubNativeSyncApi() {
|
||||
when(nativeApi.hashAssets).thenAnswer((_) async => []);
|
||||
}
|
||||
|
||||
void _stubRemoteAssetRepository() {
|
||||
when(remoteAsset.getAssetEdits).thenAnswer((_) async => []);
|
||||
when(remoteAsset.getExif).thenAnswer((_) async => null);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
final PartnerStub partner = PartnerStub(MockPartnerService());
|
||||
final UserStub user = UserStub(MockUserService());
|
||||
final asset = AssetStub(MockAssetService());
|
||||
final partner = PartnerServiceStub(MockPartnerService());
|
||||
final user = UserServiceStub(MockUserService());
|
||||
final asset = AssetServiceStub(MockAssetService());
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
@@ -69,6 +99,7 @@ class ServiceMocks {
|
||||
|
||||
void _stubAssetService() {
|
||||
when(asset.updateFavorite).thenAnswer((_) async {});
|
||||
when(asset.applyEdits).thenAnswer((_) async {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +107,40 @@ void _registerFallbacks() {
|
||||
registerFallbackValue(LocalAlbumFactory.create());
|
||||
registerFallbackValue(LocalAssetFactory.create());
|
||||
registerFallbackValue(Uint8List(0));
|
||||
registerFallbackValue(<AssetEdit>[]);
|
||||
}
|
||||
|
||||
extension type const Stub<T extends Mock>(T mockedService) {
|
||||
void reset() => mock.reset(mockedService);
|
||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||
void reset() => mock.reset(mockedClass);
|
||||
}
|
||||
|
||||
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
|
||||
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
|
||||
() => repo.getBackupAlbums();
|
||||
|
||||
Future<List<LocalAsset>> Function() get getAssetsToHash =>
|
||||
() => repo.getAssetsToHash(any());
|
||||
}
|
||||
|
||||
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
|
||||
implements Stub<MockDriftLocalAssetRepository> {
|
||||
Future<void> Function() get reconcileHashesFromCloudId =>
|
||||
() => repo.reconcileHashesFromCloudId();
|
||||
|
||||
Future<void> Function() get updateHashes =>
|
||||
() => repo.updateHashes(any());
|
||||
}
|
||||
|
||||
extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo)
|
||||
implements Stub<MockRemoteAssetRepository> {
|
||||
Future<List<AssetEdit>> Function() get getAssetEdits =>
|
||||
() => repo.getAssetEdits(any());
|
||||
|
||||
Future<ExifInfo?> Function() get getExif =>
|
||||
() => repo.getExif(any());
|
||||
}
|
||||
|
||||
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
Stream<Iterable<User>> Function() get getCandidates =>
|
||||
() => service.getCandidates(any());
|
||||
|
||||
@@ -110,7 +168,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub<Moc
|
||||
);
|
||||
}
|
||||
|
||||
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
|
||||
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
|
||||
UserDto Function() get getMyUser =>
|
||||
() => service.getMyUser();
|
||||
|
||||
@@ -127,7 +185,15 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
|
||||
() => service.createProfileImage(any(), any());
|
||||
}
|
||||
|
||||
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<void> Function() get updateFavorite =>
|
||||
() => service.updateFavorite(any(), any());
|
||||
|
||||
Future<void> Function() get applyEdits =>
|
||||
() => service.applyEdits(any(), any());
|
||||
}
|
||||
|
||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||
Future<List<HashResult>> Function() get hashAssets =>
|
||||
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
@@ -23,8 +23,8 @@ void main() {
|
||||
group('AssetDebugAction', () {
|
||||
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
@@ -32,10 +32,10 @@ void main() {
|
||||
|
||||
testWidgets('hidden for multiple assets', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(
|
||||
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
|
||||
),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
@@ -44,8 +44,8 @@ void main() {
|
||||
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
|
||||
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit.action.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../../infrastructure/repository.mock.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../riverpod_mocks.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockRemoteAssetRepository remoteAssetRepo;
|
||||
late MockAssetService assetService;
|
||||
|
||||
const supportedVersion = ServerVersion(major: 2, minor: 6, patch: 0);
|
||||
const unsupportedVersion = ServerVersion(major: 2, minor: 5, patch: 9);
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
remoteAssetRepo = context.repository.remoteAsset.repo;
|
||||
assetService = context.service.asset.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides(ServerVersion version) => [
|
||||
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(version)),
|
||||
];
|
||||
|
||||
RemoteAsset owned({AssetType type = AssetType.image}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type);
|
||||
|
||||
Future<void> pumpAction(WidgetTester tester, EditAssetAction action, {ServerVersion version = supportedVersion}) =>
|
||||
tester.pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides(version));
|
||||
|
||||
group('EditAssetAction', () {
|
||||
testWidgets('visible for a single owned editable asset on a supported server', (tester) async {
|
||||
await pumpAction(tester, EditAssetAction(assets: [owned()]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
|
||||
await pumpAction(tester, EditAssetAction(assets: [owned()]), version: unsupportedVersion);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden for more than one asset', (tester) async {
|
||||
await pumpAction(tester, EditAssetAction(assets: [owned(), owned()]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden for an asset owned by someone else', (tester) async {
|
||||
await pumpAction(tester, EditAssetAction(assets: [RemoteAssetFactory.create()]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden for a non-editable asset', (tester) async {
|
||||
await pumpAction(tester, EditAssetAction(assets: [owned(type: AssetType.video)]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('EditAssetAction onAction', () {
|
||||
testWidgets('reads the edits and exif for the asset from the repository', (tester) async {
|
||||
final asset = owned();
|
||||
await tester.pumpTestAction(context, EditAssetAction(assets: [asset]), overrides: overrides(supportedVersion));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => remoteAssetRepo.getAssetEdits(asset.id)).called(1);
|
||||
verify(() => remoteAssetRepo.getExif(asset.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('applyEdits forwards the edits to the service and waits for both ready events', (tester) async {
|
||||
late FakeWebsocketNotifier websocket;
|
||||
const edits = <AssetEdit>[];
|
||||
|
||||
late WidgetRef capturedRef;
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
Consumer(
|
||||
builder: (_, ref, _) {
|
||||
capturedRef = ref;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: [websocketProvider.overrideWith((ref) => websocket = FakeWebsocketNotifier(ref))],
|
||||
);
|
||||
|
||||
await applyEdits(capturedRef, 'asset-1', edits);
|
||||
|
||||
verify(() => assetService.applyEdits('asset-1', edits)).called(1);
|
||||
expect(websocket.waitedEvents, containsAll(['AssetEditReadyV1', 'AssetEditReadyV2']));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,30 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockAssetService assetService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
assetService = context.service.asset.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides() => [
|
||||
...context.overrides,
|
||||
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
|
||||
];
|
||||
|
||||
RemoteAsset owned({bool isFavorite = false}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
|
||||
|
||||
@@ -32,48 +28,48 @@ void main() {
|
||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
|
||||
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||
final first = owned();
|
||||
final second = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('skips owned assets already in the target state', (tester) async {
|
||||
final stale = owned();
|
||||
final alreadyFavorite = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
|
||||
await tester.pumpUntilFound(find.byType(SnackBar));
|
||||
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
|
||||
@@ -4,17 +4,19 @@ 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:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockPartnerService partnerService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
partnerService = context.service.partner.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -22,8 +24,6 @@ void main() {
|
||||
});
|
||||
|
||||
List<Override> overrides({List<User> candidates = const []}) => [
|
||||
...context.overrides,
|
||||
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
@@ -31,22 +31,24 @@ void main() {
|
||||
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.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpUntilFound(find.text(candidate.name));
|
||||
await tester.tap(find.text(candidate.name));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
|
||||
).called(1);
|
||||
verify(() => partnerService.create(sharedById: context.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.pumpTestAction(
|
||||
context,
|
||||
const PartnerAddAction(),
|
||||
overrides: overrides(candidates: [UserFactory.create()]),
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.mocks.partner.create);
|
||||
verifyNever(context.service.partner.create);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,27 +56,27 @@ void main() {
|
||||
testWidgets('deletes the partner after confirmation', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).last); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
|
||||
).called(1);
|
||||
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).first); // cancel
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.mocks.partner.delete);
|
||||
verifyNever(context.service.partner.delete);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
class _FakeAction extends BaseAction {
|
||||
_FakeAction({this.visible = true, this.error});
|
||||
@@ -48,8 +48,7 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> seededOverrides() => [
|
||||
...context.overrides,
|
||||
List<Override> overrides() => [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(
|
||||
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
|
||||
@@ -61,6 +60,7 @@ void main() {
|
||||
late ActionScope scope;
|
||||
late ProviderContainer container;
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
Consumer(
|
||||
builder: (innerContext, ref, _) {
|
||||
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
|
||||
@@ -68,7 +68,7 @@ void main() {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: seededOverrides(),
|
||||
overrides: overrides(),
|
||||
);
|
||||
return (scope, container);
|
||||
}
|
||||
@@ -97,8 +97,8 @@ void main() {
|
||||
|
||||
testWidgets('delegates visibility to the wrapped action', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
|
||||
import '../factories/partner_user_factory.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
import 'presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
@@ -19,7 +19,7 @@ void main() {
|
||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||
final action = const PartnerAddAction();
|
||||
|
||||
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
|
||||
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
|
||||
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
|
||||
@@ -28,8 +28,7 @@ void main() {
|
||||
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]), overrides: context.overrides);
|
||||
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byType(ListTile), findsNWidgets(2));
|
||||
expect(find.text(partner1.name), findsOneWidget);
|
||||
expect(find.text(partner1.email), findsOneWidget);
|
||||
@@ -41,7 +40,7 @@ void main() {
|
||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||
final partner2 = PartnerFactory.create();
|
||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
@@ -62,13 +61,12 @@ void main() {
|
||||
}
|
||||
|
||||
List<Override> withCandidates(List<User> candidates) => [
|
||||
...context.overrides,
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
|
||||
final user = UserFactory.create();
|
||||
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
|
||||
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -78,7 +76,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows no options when the provider returns no candidates', (tester) async {
|
||||
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
|
||||
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -89,7 +87,11 @@ void main() {
|
||||
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
|
||||
final user = UserFactory.create();
|
||||
User? selected;
|
||||
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
dialogWidget(onClosed: (user) => selected = user),
|
||||
overrides: withCandidates([user]),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
+27
-12
@@ -13,16 +13,21 @@ 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/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.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';
|
||||
import '../../test_utils.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
import '../mocks.dart';
|
||||
|
||||
class PresentationContext {
|
||||
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
|
||||
PresentationContext._({required UserDto user})
|
||||
: currentUser = user,
|
||||
service = ServiceMocks(),
|
||||
repository = RepositoryMocks() {
|
||||
setup();
|
||||
}
|
||||
|
||||
@@ -31,9 +36,15 @@ class PresentationContext {
|
||||
static Drift? _db;
|
||||
|
||||
final UserDto currentUser;
|
||||
final ServiceMocks mocks;
|
||||
final ServiceMocks service;
|
||||
final RepositoryMocks repository;
|
||||
|
||||
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
|
||||
List<Override> get overrides => [
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
|
||||
];
|
||||
|
||||
static Future<PresentationContext> create() async {
|
||||
TestUtils.init();
|
||||
@@ -47,18 +58,18 @@ class PresentationContext {
|
||||
}
|
||||
|
||||
void setup() {
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
when(service.user.tryGetMyUser).thenReturn(currentUser);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
addTearDown(() {
|
||||
mocks.resetAll();
|
||||
service.resetAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PumpPresentationWidget on WidgetTester {
|
||||
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
|
||||
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
@@ -69,7 +80,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: overrides,
|
||||
overrides: [...context.overrides, ...overrides],
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -86,8 +97,12 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
Future<void> pumpTestAction(
|
||||
PresentationContext context,
|
||||
BaseAction action, {
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
|
||||
class FakeServerInfoNotifier extends ServerInfoNotifier {
|
||||
FakeServerInfoNotifier([ServerVersion version = const ServerVersion(major: 2, minor: 6, patch: 0)])
|
||||
: super(MockServerInfoService()) {
|
||||
state = state.copyWith(serverVersion: version);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWebsocketNotifier extends WebsocketNotifier {
|
||||
FakeWebsocketNotifier(super.ref);
|
||||
|
||||
final List<String> waitedEvents = [];
|
||||
|
||||
@override
|
||||
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
|
||||
waitedEvents.add(event);
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,11 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -32,22 +29,20 @@ void main() {
|
||||
group('hashAssets', () {
|
||||
test('skips albums with no assets to hash', () async {
|
||||
final album = LocalAlbumFactory.create(assetCount: 0);
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(mocks.nativeApi.hashAssets);
|
||||
});
|
||||
|
||||
test('skips empty batches', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(mocks.nativeApi.hashAssets);
|
||||
});
|
||||
|
||||
test('processes assets when available', () async {
|
||||
@@ -55,15 +50,17 @@ void main() {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final result = HashResult(assetId: asset.id, hash: 'test-hash');
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [result]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset.id], result.hash);
|
||||
});
|
||||
@@ -72,16 +69,16 @@ void main() {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
@@ -89,25 +86,25 @@ void main() {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('batches by size limit', () async {
|
||||
const batchSize = 2;
|
||||
final sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
@@ -119,12 +116,9 @@ void main() {
|
||||
|
||||
final capturedCalls = <List<String>>[];
|
||||
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
capturedCalls.add(List<String>.from(assetIds));
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
@@ -136,7 +130,7 @@ void main() {
|
||||
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
||||
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
||||
|
||||
verify(() => mocks.localAsset.updateHashes(any())).called(2);
|
||||
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
|
||||
});
|
||||
|
||||
test('handles mixed success and failure in batch', () async {
|
||||
@@ -144,9 +138,9 @@ void main() {
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
(_) async => [
|
||||
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
||||
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
||||
@@ -156,7 +150,7 @@ void main() {
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset1.id], 'asset1-hash');
|
||||
});
|
||||
@@ -167,20 +161,18 @@ void main() {
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user