mirror of
https://github.com/immich-app/immich.git
synced 2026-06-25 07:54:31 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71790837a8 | |||
| 610f300fe9 | |||
| 5a14204ed2 |
@@ -3,33 +3,35 @@ import 'package:immich_mobile/domain/models/asset/base_asset.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';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final RemoteAssetRepository _remoteRepository;
|
||||
final DriftLocalAssetRepository _localRepository;
|
||||
final AssetApiRepository _apiRepository;
|
||||
|
||||
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
|
||||
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
|
||||
|
||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
|
||||
return asset is LocalAsset ? _localRepository.get(id) : _remoteRepository.get(id);
|
||||
}
|
||||
|
||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
|
||||
return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id);
|
||||
}
|
||||
|
||||
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
|
||||
return _localAssetRepository.getByChecksum(checksum);
|
||||
return _localRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||
return _remoteAssetRepository.getByChecksum(checksum);
|
||||
return _remoteRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAsset(String id) {
|
||||
return _remoteAssetRepository.get(id);
|
||||
return _remoteRepository.get(id);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
||||
@@ -37,7 +39,7 @@ class AssetService {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final stack = await _remoteAssetRepository.getStackChildren(asset);
|
||||
final stack = await _remoteRepository.getStackChildren(asset);
|
||||
// Include the primary asset in the stack as the first item
|
||||
return [asset, ...stack];
|
||||
}
|
||||
@@ -48,22 +50,31 @@ class AssetService {
|
||||
}
|
||||
|
||||
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||
return _remoteAssetRepository.getExif(id);
|
||||
return _remoteRepository.getExif(id);
|
||||
}
|
||||
|
||||
Future<List<(String, String)>> getPlaces(String userId) {
|
||||
return _remoteAssetRepository.getPlaces(userId);
|
||||
return _remoteRepository.getPlaces(userId);
|
||||
}
|
||||
|
||||
Future<(int local, int remote)> getAssetCounts() async {
|
||||
return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount());
|
||||
return (await _localRepository.getCount(), await _remoteRepository.getCount());
|
||||
}
|
||||
|
||||
Future<int> getLocalHashedCount() {
|
||||
return _localAssetRepository.getHashedCount();
|
||||
return _localRepository.getHashedCount();
|
||||
}
|
||||
|
||||
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
|
||||
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||
}
|
||||
|
||||
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiRepository.updateFavorite(remoteIds, isFavorite);
|
||||
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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/user.model.dart';
|
||||
|
||||
class ActionScope {
|
||||
@@ -21,3 +22,11 @@ abstract class BaseAction {
|
||||
|
||||
Future<void> onAction(ActionScope scope);
|
||||
}
|
||||
|
||||
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||
final Iterable<BaseAsset> assets;
|
||||
|
||||
const AssetAction({required this.assets});
|
||||
|
||||
Iterable<T> filter(ActionScope scope);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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 FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final bool favorite;
|
||||
|
||||
const FavoriteAction({required super.assets, required this.favorite});
|
||||
|
||||
@override
|
||||
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
|
||||
@override
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
||||
.where((asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite != favorite)
|
||||
.cast<RemoteAsset>();
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:ref) = scope;
|
||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
||||
final message = favorite
|
||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||
snackbar.success(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class TimelineAction extends BaseAction {
|
||||
final BaseAction action;
|
||||
|
||||
const TimelineAction({required this.action});
|
||||
|
||||
@override
|
||||
IconData get icon => action.icon;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => action.label(scope);
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => action.isVisible(scope);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
try {
|
||||
await action.onAction(scope);
|
||||
} finally {
|
||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
@@ -15,9 +14,9 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const ViewerTopAppBar({super.key});
|
||||
@@ -31,8 +30,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
final album = ref.watch(currentRemoteAlbumProvider);
|
||||
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
|
||||
@@ -46,6 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final assetForAction = [asset];
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
@@ -63,10 +61,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
},
|
||||
),
|
||||
|
||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
ActionIconButtonWidget(
|
||||
action: FavoriteAction(assets: assetForAction, favorite: !asset.isFavorite),
|
||||
),
|
||||
|
||||
ViewerKebabMenu(originalTheme: originalTheme),
|
||||
];
|
||||
@@ -107,7 +104,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
leading: const _AppBarBackButton(),
|
||||
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
|
||||
trailing: !showingDetails && !isReadonlyModeEnabled
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
|
||||
? ImmichColorOverride(
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: isInLockedView ? lockedViewActions : actions,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,12 +3,14 @@ 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/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.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/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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/share_link_action_button.widget.dart';
|
||||
@@ -74,6 +76,9 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets, favorite: true)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
@@ -84,7 +89,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
|
||||
@@ -4,6 +4,9 @@ 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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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';
|
||||
@@ -15,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
@@ -65,6 +67,9 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets, favorite: false)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.4,
|
||||
maxChildSize: 0.7,
|
||||
@@ -73,7 +78,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
const UnFavoriteActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||
isTrashEnable
|
||||
|
||||
@@ -3,13 +3,15 @@ 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/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_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/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_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/set_album_cover.widget.dart';
|
||||
@@ -83,6 +85,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = [FavoriteAction(assets: assets, favorite: true)];
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.22,
|
||||
@@ -96,7 +101,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
|
||||
if (ownsAlbum) ...[
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (ownsAlbum) ...[
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
|
||||
final localAssetRepository = Provider<DriftLocalAssetRepository>(
|
||||
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
||||
@@ -20,8 +21,9 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||
localRepository: ref.watch(localAssetRepository),
|
||||
apiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export 'src/color_override.dart';
|
||||
export 'src/components/close_button.dart';
|
||||
export 'src/components/column_button.dart';
|
||||
export 'src/components/form.dart';
|
||||
|
||||
@@ -34,6 +34,7 @@ class RepositoryMocks {
|
||||
class ServiceMocks {
|
||||
final PartnerStub partner = PartnerStub(MockPartnerService());
|
||||
final UserStub user = UserStub(MockUserService());
|
||||
final asset = AssetStub(MockAssetService());
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
@@ -43,8 +44,10 @@ class ServiceMocks {
|
||||
_registerFallbacks();
|
||||
partner.reset();
|
||||
user.reset();
|
||||
asset.reset();
|
||||
_stubUserService();
|
||||
_stubPartnerService();
|
||||
_stubAssetService();
|
||||
}
|
||||
|
||||
void _stubUserService() {
|
||||
@@ -63,6 +66,10 @@ class ServiceMocks {
|
||||
when(partner.create).thenAnswer((_) async {});
|
||||
when(partner.delete).thenAnswer((_) async {});
|
||||
}
|
||||
|
||||
void _stubAssetService() {
|
||||
when(asset.updateFavorite).thenAnswer((_) async {});
|
||||
}
|
||||
}
|
||||
|
||||
void _registerFallbacks() {
|
||||
@@ -119,3 +126,8 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
|
||||
Future<String?> Function() get createProfileImage =>
|
||||
() => service.createProfileImage(any(), any());
|
||||
}
|
||||
|
||||
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<void> Function() get updateFavorite =>
|
||||
() => service.updateFavorite(any(), any());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
group('FavoriteAction', () {
|
||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset], favorite: true), overrides: overrides());
|
||||
|
||||
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(FavoriteAction(assets: [asset], favorite: false), overrides: overrides());
|
||||
|
||||
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(FavoriteAction(assets: [mine, theirs], favorite: true), overrides: overrides());
|
||||
|
||||
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(FavoriteAction(assets: [first, second], favorite: true), overrides: overrides());
|
||||
|
||||
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(
|
||||
FavoriteAction(assets: [stale, alreadyFavorite], favorite: true),
|
||||
overrides: overrides(),
|
||||
);
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [owned()], favorite: true), overrides: overrides());
|
||||
await tester.pumpUntilFound(find.byType(SnackBar));
|
||||
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('is hidden when no asset is eligible', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ActionIconButtonWidget(action: FavoriteAction(assets: [asset], favorite: true)),
|
||||
overrides: overrides(),
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,32 +5,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../../mocks.dart';
|
||||
import '../../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late UserDto currentUser;
|
||||
final mocks = ServiceMocks();
|
||||
|
||||
setUp(() async {
|
||||
currentUser = UserFactory.createDto();
|
||||
context = await PresentationContext.create();
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
mocks.resetAll();
|
||||
await context.dispose();
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides({List<User> candidates = const []}) => [
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)),
|
||||
partnerServiceProvider.overrideWithValue(mocks.partner.service),
|
||||
...context.overrides,
|
||||
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
@@ -43,7 +36,9 @@ void main() {
|
||||
await tester.tap(find.text(candidate.name));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => mocks.partner.service.create(sharedById: 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 {
|
||||
@@ -51,7 +46,7 @@ void main() {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(mocks.partner.create);
|
||||
verifyNever(context.mocks.partner.create);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +60,9 @@ void main() {
|
||||
await tester.tap(find.byType(TextButton).last); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => mocks.partner.service.delete(sharedById: 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 {
|
||||
@@ -77,7 +74,7 @@ void main() {
|
||||
await tester.tap(find.byType(TextButton).first); // cancel
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(mocks.partner.delete);
|
||||
verifyNever(context.mocks.partner.delete);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ void main() {
|
||||
late PresentationContext context;
|
||||
|
||||
setUp(() async => context = await PresentationContext.create());
|
||||
tearDown(() async => await context.dispose());
|
||||
tearDown(() => context.dispose());
|
||||
|
||||
group('PartnerSharedByList', () {
|
||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||
|
||||
@@ -23,7 +23,7 @@ import 'mocks.dart';
|
||||
|
||||
class PresentationContext {
|
||||
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
setup();
|
||||
}
|
||||
|
||||
static const String serverEndpoint = 'http://localhost:3000';
|
||||
@@ -46,10 +46,14 @@ class PresentationContext {
|
||||
return PresentationContext._(user: UserFactory.createDto());
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
// TODO: Dispose the store and database after each test.
|
||||
// This is currently not possible because the store is a singleton and is used across tests.
|
||||
// Refactor the store to be created per test to allow proper disposal.
|
||||
void setup() {
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
addTearDown(() {
|
||||
mocks.resetAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +77,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
home: Material(child: widget),
|
||||
home: Scaffold(body: widget),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -83,10 +87,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
}
|
||||
|
||||
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||
await pumpTestWidget(
|
||||
Scaffold(body: ActionIconButtonWidget(action: action)),
|
||||
overrides: overrides,
|
||||
);
|
||||
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user