Compare commits

..

25 Commits

Author SHA1 Message Date
Yaros 58d1373a04 chore: undo unrelated button change 2026-06-27 15:36:28 +02:00
Yaros 0b68e1376a chore: bump immich/ui to 0.83.0 2026-06-27 15:34:54 +02:00
Yaros 7cbd9eada9 Merge branch 'main' into feat/undo-archive 2026-06-27 15:24:14 +02:00
Yaros 4b05d02769 Merge branch 'main' into feat/undo-archive 2026-06-27 15:23:39 +02:00
Yaros 18d0991b61 chore: format asset-utils 2026-06-25 13:04:52 +02:00
Yaros fa29dc08f9 chore(web): use immich/ui 0.82.0 2026-06-24 22:06:26 +02:00
Yaros fdcd6c7671 Merge branch 'main' into feat/undo-archive 2026-06-24 21:41:01 +02:00
Yaros 44fdaa47d3 refactor: remove undotoast component 2026-06-23 20:06:03 +02:00
Yaros 604d100bb8 chore: format undotoast 2026-06-23 19:24:55 +02:00
Yaros ae022fd90a fix: file placement & undo toast 2026-06-23 19:22:33 +02:00
Yaros afa433b3ad refactor: moved AssetsUndoArchive handler 2026-06-22 17:33:54 +02:00
Yaros 7cdc252384 refactor: use eventmanager for onundoarchive 2026-06-11 21:46:27 +02:00
Yaros 3c43b7240b fix(web): ignore unknown assets in album timelines 2026-06-08 19:12:57 +02:00
Yaros b966940264 chore: formatting 2026-06-08 19:02:47 +02:00
Yaros 72cca30272 feat: go back to asset on undo archive in viewer 2026-06-08 19:00:09 +02:00
Yaros bcd01a2464 fix: restore from asset viewer 2026-06-08 18:53:42 +02:00
Yaros a5d8415c5f fix(web): correct timeline position on undo 2026-06-08 18:48:10 +02:00
Yaros 74e9ec4872 fix: import conflicts 2026-06-08 18:29:44 +02:00
Yaros 411487dfd7 Merge branch 'main' into feat/undo-archive 2026-06-08 18:13:43 +02:00
Yaros 524b191ccc refactor: use event-manager 2026-03-27 13:55:42 +01:00
Yaros 075f7d507b refactor: use primary toast 2026-03-26 19:20:11 +01:00
Yaros c4df4d7852 Merge branch 'main' into feat/undo-archive 2026-03-26 19:11:36 +01:00
Yaros 0eaa2c3419 refactor: remove ternary 2026-03-20 17:43:22 +01:00
Yaros 554e7b28a2 refactor: remove unnecessary checks 2026-03-20 17:13:18 +01:00
Yaros 167aad7ac2 feat(web): undo archive from toast 2026-03-19 22:13:34 +01:00
32 changed files with 362 additions and 383 deletions
@@ -77,13 +77,4 @@ class AssetService {
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> restoreTrash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.restoreTrash(remoteIds);
await _remoteRepository.restoreTrash(remoteIds);
}
}
@@ -1,30 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_ui/immich_ui.dart';
class RestoreAction extends AssetAction<RemoteAsset> {
const RestoreAction({required super.assets});
@override
IconData get icon => Icons.history_rounded;
@override
String label(ActionScope scope) => scope.context.t.restore;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
assets.whereType<RemoteAsset>().where((asset) => asset.ownerId == scope.authUser.id && asset.isTrashed);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ids = filter(scope).map((asset) => asset.id).toList(growable: false);
await scope.ref.read(assetServiceProvider).restoreTrash(ids);
snackbar.success(StaticTranslations.instance.assets_restored_count(count: ids.length));
}
}
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.history_rounded,
label: 'restore'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100.0,
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RestoreTrashActionButton extends ConsumerWidget {
final ActionSource source;
const RestoreTrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return TextButton.icon(
icon: const Icon(Icons.history_rounded),
label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}
@@ -4,13 +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/restore.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';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
@@ -43,10 +42,11 @@ class ViewerBottomBar extends ConsumerWidget {
final originalTheme = context.themeData;
final assets = [asset];
final actions = <Widget>[
ActionColumnButtonWidget(action: RestoreAction(assets: assets)),
const ShareActionButton(source: ActionSource.viewer),
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
@@ -2,33 +2,26 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/restore.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart';
class TrashBottomBar extends ConsumerWidget {
const TrashBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
return Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.themeData.canvasColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: SafeArea(
child: const SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const DeleteTrashActionButton(source: ActionSource.timeline),
ActionColumnButtonWidget(
action: TimelineAction(action: RestoreAction(assets: assets)),
),
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
),
),
@@ -235,6 +235,17 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> restoreTrash(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.restoreTrash(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to restore trash assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> emptyTrash(String userId) async {
try {
final count = await _service.emptyTrash(userId);
+5
View File
@@ -108,6 +108,11 @@ class ActionService {
await _remoteAssetRepository.trash(remoteIds);
}
Future<void> restoreTrash(List<String> ids) async {
await _assetApiRepository.restoreTrash(ids);
await _remoteAssetRepository.restoreTrash(ids);
}
Future<int> emptyTrash(String userId) async {
final count = await _assetApiRepository.emptyTrash();
await _remoteAssetRepository.emptyTrash(userId);
+6 -2
View File
@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
@@ -22,6 +21,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_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/set_album_cover.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
@@ -208,7 +208,11 @@ enum ActionButtonType {
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => ActionMenuItemWidget(action: RestoreAction(assets: [context.asset])),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
-13
View File
@@ -1,13 +0,0 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
Logger.root.level = Level.OFF;
EasyLocalization.logger.enableBuildModes = [];
// ignore: banned-usage
debugPrint = (String? message, {int? wrapWidth}) {};
return testMain();
}
@@ -1,4 +1,5 @@
@Skip('Flaky test, needs investigation')
@Tags(['widget'])
library;
import 'package:drift/drift.dart';
@@ -5,13 +5,7 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
DateTime? deletedAt,
}) {
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -24,7 +18,6 @@ class RemoteAssetFactory {
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
isEdited: false,
deletedAt: deletedAt,
);
}
}
+14 -60
View File
@@ -1,10 +1,7 @@
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/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';
@@ -15,11 +12,11 @@ import 'factories/local_asset_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
final nativeApi = MockNativeSyncApi();
RepositoryMocks() {
resetAll();
@@ -27,34 +24,17 @@ class RepositoryMocks {
void resetAll() {
_registerFallbacks();
localAlbum.reset();
localAsset.reset();
reset(localAlbum);
reset(localAsset);
reset(trashedAsset);
nativeApi.reset();
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
}
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 => []);
reset(nativeApi);
}
}
class ServiceMocks {
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -89,7 +69,6 @@ class ServiceMocks {
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.restoreTrash).thenAnswer((_) async {});
}
}
@@ -99,28 +78,11 @@ void _registerFallbacks() {
registerFallbackValue(Uint8List(0));
}
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
extension type const Stub<T extends Mock>(T mockedService) {
void reset() => mock.reset(mockedService);
}
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 PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -148,7 +110,7 @@ extension type const PartnerServiceStub(MockPartnerService service) implements S
);
}
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
UserDto Function() get getMyUser =>
() => service.getMyUser();
@@ -165,15 +127,7 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
() => service.createProfileImage(any(), any());
}
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get restoreTrash =>
() => service.restoreTrash(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);
@@ -1,26 +1,30 @@
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);
@@ -28,48 +32,48 @@ void main() {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
verify(() => context.mocks.asset.service.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(context, FavoriteAction(assets: [mine, theirs]));
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
verify(() => context.mocks.asset.service.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(context, FavoriteAction(assets: [first, second]));
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
verify(() => context.mocks.asset.service.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(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
@@ -4,19 +4,17 @@ 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(() {
@@ -24,6 +22,8 @@ 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,24 +31,22 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
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(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
const PartnerAddAction(),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.service.partner.create);
verifyNever(context.mocks.partner.create);
});
});
@@ -56,27 +54,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(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
verify(
() => context.mocks.partner.service.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.service.partner.delete);
verifyNever(context.mocks.partner.delete);
});
});
}
@@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.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();
});
RemoteAsset owned({bool trashed = true}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null);
group('RestoreAction', () {
testWidgets('restores the eligible owned trashed assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, RestoreAction(assets: [asset]));
verify(() => assetService.restoreTrash([asset.id])).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
await tester.pumpTestAction(context, RestoreAction(assets: [mine, theirs]));
verify(() => assetService.restoreTrash([mine.id])).called(1);
});
testWidgets('skips owned assets that are not trashed', (tester) async {
final trashed = owned();
final live = owned(trashed: false);
await tester.pumpTestAction(context, RestoreAction(assets: [trashed, live]));
verify(() => assetService.restoreTrash([trashed.id])).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, RestoreAction(assets: [first, second]));
verify(() => assetService.restoreTrash([first.id, second.id])).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, RestoreAction(assets: [owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
}
@@ -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,7 +48,8 @@ void main() {
context.dispose();
});
List<Override> overrides() => [
List<Override> seededOverrides() => [
...context.overrides,
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
@@ -60,7 +61,6 @@ 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: overrides(),
overrides: seededOverrides(),
);
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(context, const PartnerSharedByList(partners: []));
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
@@ -28,7 +28,8 @@ 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(context, PartnerSharedByList(partners: [partner1, partner2]));
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
@@ -40,7 +41,7 @@ void main() {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byIcon(action.icon), findsNWidgets(2));
});
});
@@ -61,12 +62,13 @@ 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(context, dialogWidget(), overrides: withCandidates([user]));
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -76,7 +78,7 @@ void main() {
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -87,11 +89,7 @@ void main() {
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(
context,
dialogWidget(onClosed: (user) => selected = user),
overrides: withCandidates([user]),
);
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -13,21 +13,16 @@ 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,
service = ServiceMocks(),
repository = RepositoryMocks() {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
setup();
}
@@ -36,14 +31,9 @@ class PresentationContext {
static Drift? _db;
final UserDto currentUser;
final ServiceMocks service;
final RepositoryMocks repository;
final ServiceMocks mocks;
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
];
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
static Future<PresentationContext> create() async {
TestUtils.init();
@@ -57,18 +47,18 @@ class PresentationContext {
}
void setup() {
when(service.user.tryGetMyUser).thenReturn(currentUser);
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
service.resetAll();
mocks.resetAll();
});
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -79,7 +69,7 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [...context.overrides, ...overrides],
overrides: overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -96,12 +86,8 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}
@@ -14,11 +14,14 @@ void main() {
setUp(() {
sut = HashService(
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
});
tearDown(() {
@@ -29,20 +32,22 @@ 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.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(mocks.nativeApi.hashAssets);
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips empty batches', () async {
final album = LocalAlbumFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
await sut.hashAssets();
verifyNever(mocks.nativeApi.hashAssets);
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('processes assets when available', () async {
@@ -50,17 +55,15 @@ 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.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [result]);
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]);
await sut.hashAssets();
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset.id], result.hash);
});
@@ -69,16 +72,16 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
@@ -86,25 +89,25 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.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.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
);
@@ -116,9 +119,12 @@ void main() {
final capturedCalls = <List<String>>[];
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 {
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 {
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();
@@ -130,7 +136,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.repo.updateHashes(any())).called(2);
verify(() => mocks.localAsset.updateHashes(any())).called(2);
});
test('handles mixed success and failure in batch', () async {
@@ -138,9 +144,9 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
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(
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(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
@@ -150,7 +156,7 @@ void main() {
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset1.id], 'asset1-hash');
});
@@ -161,18 +167,20 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
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 {
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 {
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.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
});
+9 -33
View File
@@ -1,51 +1,27 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
extension PumpConsumerWidget on WidgetTester {
/// Wraps the provided [widget] with a localized Material app such that it
/// becomes:
///
/// EasyLocalization
/// |-ProviderScope
/// |-MaterialApp (localization delegates wired up)
/// |-Material
/// |-[widget]
/// Wraps the provided [widget] with Material app such that it becomes:
///
/// ProviderScope
/// |-MaterialApp
/// |-Material
/// |-[widget]
Future<void> pumpConsumerWidget(
Widget widget, {
Duration? duration,
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
List<Override> overrides = const [],
}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Material(child: widget),
),
),
),
return pumpWidget(
ProviderScope(
overrides: overrides,
child: MaterialApp(debugShowCheckedModeBanner: false, home: Material(child: widget)),
),
duration: duration,
phase: phase,
);
await pumpAndSettle();
}
}
+10 -10
View File
@@ -786,8 +786,8 @@ importers:
specifier: workspace:*
version: link:../packages/sdk
'@immich/ui':
specifier: ^0.81.1
version: 0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))
specifier: ^0.83.0
version: 0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
@@ -3250,8 +3250,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true
'@immich/ui@0.81.1':
resolution: {integrity: sha512-7g173hArs7OS5CfHUG+ZJVQp1iCvFZzBmm+f2uH1WChlFdcwty5DLilYrDBszWuPIvu8wTX2AXTneS/KYBCUxw==}
'@immich/ui@0.83.0':
resolution: {integrity: sha512-Xh3R3yhn8/Qyq8lGWIiVmturA+dHXtmW/h++AGb2UAwS/58qu7ssocO/HWIamaSpkgjPGotlWpPIpUJq1zh9FQ==}
peerDependencies:
'@sveltejs/kit': ^2.13.0
svelte: ^5.0.0
@@ -16111,7 +16111,7 @@ snapshots:
pg-connection-string: 2.13.0
postgres: 3.4.9
'@immich/ui@0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))':
'@immich/ui@0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))':
dependencies:
'@internationalized/date': 3.12.2
'@mdi/js': 7.4.47
@@ -21092,21 +21092,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exiftool-vendored.exe@13.59.0:
optional: true
exiftool-vendored.exe@13.59.0: {}
exiftool-vendored.pl@13.59.0: {}
exiftool-vendored.pl@13.59.0:
optional: true
exiftool-vendored@35.21.0:
dependencies:
'@photostructure/tz-lookup': 11.5.0
'@types/luxon': 3.7.1
batch-cluster: 17.3.1
exiftool-vendored.pl: 13.59.0
exiftool-vendored.exe: 13.59.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.59.0
exiftool-vendored.pl: 13.59.0
expand-template@2.0.3:
optional: true
+1 -1
View File
@@ -66,4 +66,4 @@ injectWorkspacePackages: true
shamefullyHoist: false
verifyDepsBeforeRun: install
minimumReleaseAgeExclude:
- '@immich/ui@0.81.1'
- '@immich/ui@0.83.0'
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.81.1",
"@immich/ui": "^0.83.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
@@ -14,6 +14,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetActions } from '$lib/services/asset.service';
import { faceManager } from '$lib/stores/face.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
@@ -23,6 +24,7 @@
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -149,6 +151,15 @@
}
};
const onAssetsUndoArchive = async (assets: TimelineAsset[]) => {
if (assets.length === 0) {
return;
}
const restoredAsset = assets[0];
await assetViewerManager.setAssetId(restoredAsset.id);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
};
onMount(() => {
syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@@ -475,7 +486,7 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<OnEvents {onAssetUpdate} />
<OnEvents {onAssetUpdate} {onAssetsUndoArchive} />
<svelte:document
bind:fullscreenElement
@@ -309,12 +309,12 @@
untrack(() => map?.jumpTo({ center, zoom }));
});
const onAssetsDelete = async () => {
const onAssetsChanged = async () => {
mapMarkers = await loadMapMarkers();
};
</script>
<OnEvents {onAssetsDelete} />
<OnEvents onAssetsDelete={onAssetsChanged} onAssetsArchive={onAssetsChanged} onAssetsUnarchive={onAssetsChanged} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
@@ -16,6 +16,7 @@ import type {
UserAdminResponseDto,
WorkflowResponseDto,
} from '@immich/sdk';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { TreeNode } from '$lib/utils/tree-utils';
@@ -35,6 +36,8 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsUnarchive: [TimelineAsset[]];
AssetsUndoArchive: [TimelineAsset[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -1,4 +1,4 @@
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
import { AssetOrder, AssetOrderBy, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
@@ -114,7 +114,15 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
AssetUpdate: (asset: AssetResponseDto) => {
const timelineAsset = toTimelineAsset(asset);
if (this.#options.albumId || this.#options.personId) {
this.#updateAssets([timelineAsset]);
} else {
this.upsertAssets([timelineAsset]);
}
},
AssetsUnarchive: (assets) => this.upsertAssets(assets),
}),
);
}
+54 -8
View File
@@ -17,13 +17,14 @@ import {
type StackResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { toastManager, type ToastShow } from '@immich/ui';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
@@ -31,6 +32,7 @@ import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { handleError } from './handle-error';
export const tagAssets = async ({
@@ -400,7 +402,12 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
});
asset.isArchived = data.isArchived;
toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
if (asset.isArchived) {
const timelineAsset = toTimelineAsset(asset);
showUndoArchiveToast($t('added_to_archive'), [timelineAsset]);
} else {
toastManager.primary($t('removed_from_archive'));
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
}
@@ -408,7 +415,46 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
return asset;
};
export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => {
const showUndoArchiveToast = (description: string, assets: TimelineAsset[]) => {
const $t = get(t);
const toast: ToastShow & { onClose?: () => void } = {
description,
button: (close) => ({
label: $t('undo'),
onclick: () => {
close();
void undoArchiveAssets(assets);
},
}),
};
toastManager.primary(toast);
};
const undoArchiveAssets = async (assets: TimelineAsset[]) => {
const $t = get(t);
try {
const ids = assets.map((a) => a.id);
if (ids.length > 0) {
await updateAssets({
assetBulkUpdateDto: {
ids,
visibility: AssetVisibility.Timeline,
},
});
}
for (const asset of assets) {
asset.visibility = AssetVisibility.Timeline;
}
eventManager.emit('AssetsUnarchive', assets);
eventManager.emit('AssetsUndoArchive', assets);
toastManager.success($t('unarchived_count', { values: { count: assets.length } }));
} catch (error) {
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: false } }));
}
};
export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVisibility) => {
const ids = assets.map(({ id }) => id);
const $t = get(t);
@@ -419,11 +465,11 @@ export const archiveAssets = async (assets: { id: string }[], visibility: AssetV
});
}
toastManager.primary(
visibility === AssetVisibility.Archive
? $t('archived_count', { values: { count: ids.length } })
: $t('unarchived_count', { values: { count: ids.length } }),
);
if (visibility === AssetVisibility.Archive) {
showUndoArchiveToast($t('archived_count', { values: { count: ids.length } }), assets);
} else {
toastManager.primary($t('unarchived_count', { values: { count: ids.length } }));
}
} catch (error) {
handleError(
error,
+4 -1
View File
@@ -165,7 +165,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
const people = assetResponse.people?.map((person) => person.name) || [];
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
// Keep this consistent with the bucket loader (getTimes), which stores fileCreatedAt as UTC
// components. The timeline sorts assets within a day by fileCreatedAt, so a mismatched
// representation here would place re-inserted assets (e.g. undo archive) in the wrong spot.
const fileCreatedAt = fromISODateTimeUTCToObject(assetResponse.fileCreatedAt);
const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt);
return {
@@ -329,6 +329,7 @@
onPersonAssetDelete={handlePersonAssetDelete}
onAssetsDelete={updateAssetCount}
onAssetsArchive={updateAssetCount}
onAssetsUnarchive={updateAssetCount}
/>
<main