Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
1f0c662965 refactor(mobile): actions 2026-07-22 13:25:05 -04:00
36 changed files with 322 additions and 680 deletions

View File

@@ -1459,10 +1459,8 @@
"move_to": "Move to",
"move_to_device_trash": "Move to device trash",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_lock_folder_partial_prompt": "{count} added to the locked folder, {kept} local copies were kept",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_to_locked_folder_local_ios": "These items will be deleted from Photos, but will still be available on the Immich server. They will be in Recently Deleted for 30 days.",
"moved_to_trash": "Moved to trash",
"mute_memories": "Mute Memories",
"my_albums": "My albums",

View File

@@ -9,6 +9,8 @@ enum SortOrder {
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }

View File

@@ -6,7 +6,6 @@ const Map<String, Locale> locales = {
// Additional locales
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Belarusian (be)': Locale('be'),
'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),

View File

@@ -10,7 +10,6 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
@@ -293,20 +292,4 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()!).get();
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
final companion = RemoteAssetEntityCompanion(
visibility: visibility.toDriftValue(),
isFavorite: isFavorite.toDriftValue(),
);
return _db.batch((batch) {
for (final remoteId in remoteIds) {
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
}
});
}
}

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
@@ -33,7 +33,7 @@ class PartnerPage extends ConsumerWidget {
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
actions: const [PartnerAddAction(display: ActionDisplay.iconButton)],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
@@ -60,7 +60,7 @@ class _EmptyPartners extends StatelessWidget {
),
const Align(
alignment: .center,
child: ActionButtonWidget(action: PartnerAddAction()),
child: PartnerAddAction(),
),
],
),
@@ -88,8 +88,10 @@ class PartnerSharedByList extends StatelessWidget {
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: ActionIconButtonWidget(
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
trailing: PartnerRemoveAction(
sharedWithId: partner.id,
partnerName: partner.name,
display: ActionDisplay.iconButton,
),
);
},

View File

@@ -1,32 +1,17 @@
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';
import 'package:flutter/widgets.dart' hide Action;
class ActionScope {
final BuildContext context;
final WidgetRef ref;
final UserDto authUser;
enum ActionDisplay { button, iconButton, menuItem, columnButton }
const ActionScope({required this.context, required this.ref, required this.authUser});
}
abstract class BaseAction {
const BaseAction();
IconData get icon;
String label(ActionScope scope);
bool isVisible(ActionScope scope) => true;
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) => assets.whereType<T>();
class Action {
final IconData icon;
final bool isVisible;
final String label;
final Future<void> Function() onAction;
const Action({
required this.icon,
this.isVisible = true,
required this.label,
required this.onAction,
});
}

View File

@@ -1,95 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
class _ActionWidgetScope {
final String label;
final VoidCallback onAction;
abstract class ActionWidget extends ConsumerWidget {
final ActionDisplay display;
const _ActionWidgetScope({required this.label, required this.onAction});
}
const ActionWidget({super.key, this.display = ActionDisplay.button});
class _ActionWidget extends ConsumerWidget {
final BaseAction action;
final Widget Function(_ActionWidgetScope context) builder;
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction(ActionScope scope) async {
try {
await action.onAction(scope);
} catch (error, stackTrace) {
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
Action resolve(BuildContext context, WidgetRef ref);
@override
Widget build(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
final action = resolve(context, ref);
if (!action.isVisible) {
return const SizedBox.shrink();
}
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
if (!action.isVisible(scope)) {
return const SizedBox.shrink();
}
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
return switch (display) {
ActionDisplay.button =>
ImmichTextButton(labelText: action.label, icon: action.icon, onPressed: action.onAction),
ActionDisplay.iconButton => ImmichIconButton(icon: action.icon, onPressed: action.onAction),
ActionDisplay.menuItem =>
ImmichMenuItem(icon: action.icon, label: action.label, onPressed: action.onAction),
ActionDisplay.columnButton =>
ImmichColumnButton(icon: action.icon, label: action.label, onPressed: action.onAction),
};
}
}
class ActionIconButtonWidget extends StatelessWidget {
final BaseAction action;
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
);
}
class ActionButtonWidget extends StatelessWidget {
final BaseAction action;
final ImmichVariant variant;
const ActionButtonWidget({super.key, required this.action, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) =>
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
);
}
class ActionColumnButtonWidget extends StatelessWidget {
final BaseAction action;
const ActionColumnButtonWidget({super.key, required this.action});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
class ActionMenuItemWidget extends StatelessWidget {
final BaseAction action;
const ActionMenuItemWidget({super.key, required this.action});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
);
}

View File

@@ -0,0 +1,33 @@
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/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
typedef AssetActionSelection = ({Set<BaseAsset> assets, void Function() clearSelect});
final assetsActionProvider = Provider.family<AssetActionSelection, ActionSource>((ref, source) {
final assets = switch (source) {
ActionSource.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)),
ActionSource.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) {
BaseAsset asset => {asset},
null => const <BaseAsset>{},
},
};
return (
assets: assets,
clearSelect: () {
if (source == ActionSource.timeline) {
ref.read(multiSelectProvider.notifier).reset();
}
},
);
});
abstract class AssetAction extends ActionWidget {
final ActionSource source;
const AssetAction({super.key, super.display, this.source = ActionSource.timeline});
}

View File

@@ -1,27 +1,30 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
class AssetDebugAction extends AssetAction {
const AssetDebugAction({super.key, super.display, super.source});
@override
IconData get icon => Icons.help_outline_rounded;
Action resolve(BuildContext context, WidgetRef ref) {
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
Future<void> onAction(ActionScope scope) async =>
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
return Action(
icon: Icons.help_outline_rounded,
isVisible: assets.length == 1 && advancedTroubleshooting,
label: context.t.troubleshoot,
onAction: () async {
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
clearSelect();
},
);
}
}

View File

@@ -1,38 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.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/presentation/actions/asset_action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool favorite;
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
class FavoriteAction extends AssetAction {
const FavoriteAction({super.key, super.display, super.source});
@override
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
Action resolve(BuildContext context, WidgetRef ref) {
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
final userId = ref.read(currentUserProvider)?.id;
final targets = userId == null
? const <RemoteAsset>[]
: AssetFilter(assets).owned(userId).favorite(isFavorite: false).toList(growable: false);
@override
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
@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);
return Action(
icon: Icons.favorite_border_rounded,
isVisible: targets.isNotEmpty,
label: context.t.favorite,
onAction: () async {
final ids = targets.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(ids, true);
snackbar.success(StaticTranslations.instance.favorite_action_prompt(count: ids.length));
clearSelect();
},
);
}
}
class UnfavoriteAction extends AssetAction {
const UnfavoriteAction({super.key, super.display, super.source});
@override
Action resolve(BuildContext context, WidgetRef ref) {
final (:assets, :clearSelect) = ref.watch(assetsActionProvider(source));
final userId = ref.read(currentUserProvider)?.id;
final targets = userId == null
? const <RemoteAsset>[]
: AssetFilter(assets).owned(userId).favorite(isFavorite: true).toList(growable: false);
return Action(
icon: Icons.favorite_rounded,
isVisible: targets.isNotEmpty,
label: context.t.unfavorite,
onAction: () async {
final ids = targets.map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(ids, false);
snackbar.success(StaticTranslations.instance.unfavorite_action_prompt(count: ids.length));
clearSelect();
},
);
}
}

View File

@@ -1,69 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Action;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
const PartnerAddAction();
class PartnerAddAction extends ActionWidget {
const PartnerAddAction({super.key, super.display});
@override
IconData get icon => Icons.person_add_rounded;
Action resolve(BuildContext context, WidgetRef ref) {
return Action(
icon: Icons.person_add_rounded,
label: context.t.add_partner,
onAction: () async {
final authUser = ref.read(currentUserProvider);
if (authUser == null) {
return;
}
@override
String label(ActionScope scope) => scope.context.t.add_partner;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
return;
}
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
return;
}
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
await ref.read(partnerServiceProvider).create(sharedById: authUser.id, sharedWithId: selected.id);
},
);
}
}
class PartnerRemoveAction extends BaseAction {
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
class PartnerRemoveAction extends ActionWidget {
final String sharedWithId;
final String partnerName;
@override
IconData get icon => Icons.person_remove_rounded;
const PartnerRemoveAction({super.key, super.display, required this.sharedWithId, required this.partnerName});
@override
String label(ActionScope scope) => scope.context.t.remove;
Action resolve(BuildContext context, WidgetRef ref) {
return Action(
icon: Icons.person_remove_rounded,
label: context.t.remove,
onAction: () async {
final authUser = ref.read(currentUserProvider);
if (authUser == null) {
return;
}
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:context, :ref, :authUser) = scope;
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.stop_photo_sharing,
content: context.t.partner_page_stop_sharing_content(partner: partnerName),
),
);
if (confirmed != true) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.stop_photo_sharing,
content: context.t.partner_page_stop_sharing_content(partner: partnerName),
),
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
},
);
if (confirmed != true) {
return;
}
await ref.read(partnerServiceProvider).delete(sharedById: authUser.id, sharedWithId: sharedWithId);
}
}
@visibleForTesting
final candidatesStateProvider = StreamProvider.autoDispose<Iterable<User>>((ref) {
final currentUser = ref.watch(currentUserProvider);
// TODO: Refactor with a route guard to avoid this check in every provider
if (currentUser == null) {
return const Stream.empty();
}

View File

@@ -1,24 +0,0 @@
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 {
await action.onAction(scope);
scope.ref.read(multiSelectProvider.notifier).reset();
}
}

View File

@@ -886,6 +886,7 @@ class _QuickLinkList extends StatelessWidget {
_QuickLink(
title: context.t.recently_added,
icon: Icons.upload_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
),
_QuickLink(

View File

@@ -16,23 +16,17 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
return;
}
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source, context);
if (result == null) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = result.failedCount > 0
? 'move_to_lock_folder_partial_prompt'.t(
context: context,
args: {'count': result.count.toString(), 'kept': result.failedCount.toString()},
)
: 'move_to_lock_folder_action_prompt'.t(context: context, args: {'count': result.count.toString()});
final successMessage = 'move_to_lock_folder_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(

View File

@@ -2,9 +2,10 @@ 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/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_viewer/viewer_kebab_menu.widget.dart';
@@ -43,7 +44,6 @@ 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),
@@ -61,7 +61,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
const FavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton),
const UnfavoriteAction(source: ActionSource.viewer, display: ActionDisplay.iconButton),
ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)),
];

View File

@@ -3,9 +3,8 @@ 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/action.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';
@@ -76,9 +75,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
@@ -89,7 +85,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)

View File

@@ -4,9 +4,8 @@ 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/action.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';
@@ -74,9 +73,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
@@ -85,7 +81,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable

View File

@@ -3,9 +3,8 @@ 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/action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.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/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -83,9 +82,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
@@ -93,7 +89,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const AssetDebugAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),

View File

@@ -3,9 +3,8 @@ 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/action.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';
@@ -85,9 +84,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
@@ -101,7 +97,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteAction(source: ActionSource.timeline, display: ActionDisplay.columnButton),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -25,7 +24,6 @@ import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -70,11 +68,14 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
}
List<String> _getLocalIdsForSource(ActionSource source) {
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
final Set<BaseAsset> assets = _getAssets(source);
final List<String> localIds = [];
for (final asset in assets) {
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
continue;
}
if (asset is LocalAsset) {
localIds.add(asset.id);
} else if (asset is RemoteAsset && asset.localId != null) {
@@ -180,28 +181,12 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult?> moveToLockFolder(ActionSource source, BuildContext context) async {
final assets = _getOwnedRemoteAssetsForSource(source);
final ids = assets.toIds().toList(growable: false);
final localIds = assets.map((asset) => asset.localId).nonNulls.toList(growable: false);
if (localIds.isNotEmpty) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: "move_to_locked_folder",
content: CurrentPlatform.isAndroid ? "delete_dialog_alert_local" : "move_to_locked_folder_local_ios",
ok: "confirm",
),
);
if (confirmed != true) {
return null;
}
}
Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
try {
final deletedCount = await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: true, failedCount: localIds.length - deletedCount);
await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to move assets to lock folder', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());

View File

@@ -1,4 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/toast.repository.dart';
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());

View File

@@ -1,14 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
import 'package:openapi/api.dart';
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -43,7 +41,7 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
@@ -79,11 +77,11 @@ class AssetApiRepository extends ApiRepository {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
AssetVisibility.timeline => api.AssetVisibility.timeline,
AssetVisibility.hidden => api.AssetVisibility.hidden,
AssetVisibility.locked => api.AssetVisibility.locked,
AssetVisibility.archive => api.AssetVisibility.archive,
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
@@ -108,20 +106,6 @@ class AssetApiRepository extends ApiRepository {
Future<void> removeEdits(String assetId) async {
return _api.removeAssetEdits(assetId);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: remoteIds,
isFavorite: isFavorite.toOptional(),
visibility: visibility.map(_mapVisibility).toOptional(),
),
);
}
}
extension on StackResponseDto {

View File

@@ -45,9 +45,9 @@ class AssetMediaRepository {
return false;
}
Future<List<String>> deleteAll(List<String> ids, {bool trash = true}) async {
Future<List<String>> deleteAll(List<String> ids) async {
if (CurrentPlatform.isAndroid) {
if (trash && await _androidSupportsTrash()) {
if (await _androidSupportsTrash()) {
return PhotoManager.editor.android.moveToTrash(
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
);

View File

@@ -1,26 +0,0 @@
import 'dart:async';
import 'package:immich_ui/immich_ui.dart';
class ToastOption {
final Duration? timeout;
final FutureOr<void> Function()? onUndo;
const ToastOption({this.timeout, this.onUndo});
}
class ToastRepository {
const ToastRepository();
FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
}
FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
}
FutureOr<void> error(String message, {ToastOption? toast}) {
snackbar.error(message, duration: toast?.timeout);
}
}

View File

@@ -79,28 +79,27 @@ class ActionService {
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .archive);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<int> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .locked);
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Locked assets stay on the server, so permanently delete the local copies instead of trashing them
if (localIds.isEmpty) {
return 0;
// Ask user if they want to delete local copies
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
return _deleteLocalAssets(localIds, trash: false);
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
@@ -314,12 +313,12 @@ class ActionService {
}
}
Future<int> _deleteLocalAssets(List<String> localIds, {bool trash = true}) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds, trash: trash);
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
return 0;
}
if (trash && CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
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/action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.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';
@@ -192,7 +192,7 @@ enum ActionButtonType {
bool menuItem = false,
]) {
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => AssetDebugAction(source: context.source, display: ActionDisplay.menuItem),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,

View File

@@ -1,4 +1,3 @@
import 'package:drift/drift.dart';
import 'package:openapi/api.dart' show Optional;
sealed class Option<T> {
@@ -22,11 +21,6 @@ sealed class Option<T> {
None() => null,
};
Option<U> map<U>(U Function(T value) f) => switch (this) {
Some(:final value) => Some(f(value)),
None() => None<U>(),
};
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
Some(:final value) => onSome(value),
None() => onNone(),
@@ -71,10 +65,3 @@ extension OptionToOptional<T> on Option<T> {
Some(:final value) => Optional.present(value),
};
}
extension OptionToDriftValue<T> on Option<T> {
Value<T> toDriftValue() => switch (this) {
Some(:final value) => Value(value),
None() => const Value.absent(),
};
}

View File

@@ -6,23 +6,18 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
String message,
SnackbarType type, {
Duration? duration,
}) {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
duration ??= const .new(seconds: 4);
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type, duration));
return messenger.showSnackBar(_build(context, message, type));
}
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
SnackBar _build(BuildContext context, String message, SnackbarType type) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
@@ -34,7 +29,7 @@ class SnackbarManager {
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: duration,
duration: const .new(seconds: 4),
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
@@ -53,14 +48,11 @@ class SnackbarManager {
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
}
const snackbar = SnackbarManager();

View File

@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -10,14 +10,11 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../widget_tester_extensions.dart';
class MockActionService extends Mock implements ActionService {}
class MockAssetService extends Mock implements AssetService {}
@@ -80,9 +77,7 @@ void main() {
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container
.read(actionProvider.notifier)
.editDateTime(ActionSource.viewer, FakeBuildContext());
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
expect(result?.success, isTrue);
await container.read(assetExifProvider(_asset).future);
@@ -94,9 +89,7 @@ void main() {
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container
.read(actionProvider.notifier)
.editDateTime(ActionSource.timeline, FakeBuildContext());
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
expect(result?.success, isTrue);
await container.read(assetExifProvider(_asset).future);
@@ -109,78 +102,11 @@ void main() {
container.listen(assetExifProvider(_asset), (_, __) {});
await container.read(assetExifProvider(_asset).future);
final result = await container
.read(actionProvider.notifier)
.editDateTime(ActionSource.viewer, FakeBuildContext());
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
expect(result, isNull);
await container.read(assetExifProvider(_asset).future);
verify(() => assetService.getExif(_asset)).called(1);
});
});
group('moveToLockFolder', () {
Future<ActionResult?> runAction(WidgetTester tester) async {
late BuildContext context;
await tester.pumpConsumerWidget(
Builder(
builder: (value) {
context = value;
return const SizedBox.shrink();
},
),
);
final result = container.read(actionProvider.notifier).moveToLockFolder(ActionSource.timeline, context);
await tester.pumpAndSettle();
await tester.tap(find.byType(TextButton).last);
await tester.pumpAndSettle();
return result;
}
testWidgets('keeps success and reports kept local copies when the OS delete is declined', (tester) async {
final asset = _asset.copyWith(localId: 'local-owned');
container.read(multiSelectProvider.notifier).selectAsset(asset);
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 0);
final result = await runAction(tester);
expect(result?.success, isTrue);
expect(result?.failedCount, 1);
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
});
testWidgets('reports kept local copies when only some are deleted', (tester) async {
final first = _asset.copyWith(localId: 'local-1');
final second = _asset.copyWith(id: 'asset-2', localId: 'local-2', checksum: 'checksum-2');
container.read(multiSelectProvider.notifier)
..selectAsset(first)
..selectAsset(second);
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
final result = await runAction(tester);
expect(result?.success, isTrue);
expect(result?.failedCount, 1);
});
testWidgets('deletes only owned local copies from a mixed selection', (tester) async {
final owned = _asset.copyWith(localId: 'local-owned');
final partner = _asset.copyWith(
id: 'asset-2',
localId: 'local-partner',
ownerId: 'partner-1',
checksum: 'checksum-2',
);
container.read(multiSelectProvider.notifier)
..selectAsset(owned)
..selectAsset(partner);
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
final result = await runAction(tester);
expect(result?.success, isTrue);
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
});
});
}

View File

@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -148,13 +147,13 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
@@ -163,13 +162,13 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
@@ -178,73 +177,14 @@ void main() {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => <String>[]);
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
final result = await sut.deleteLocal(ids);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
});
group('ActionService.moveToLockFolder', () {
const remoteIds = ['r1', 'r2'];
const localIds = ['l1', 'l2'];
test('permanently deletes local copies without trashing, even when Android trash handling is on', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => localIds);
when(() => localAssetRepository.delete(localIds)).thenAnswer((_) async {});
final result = await sut.moveToLockFolder(remoteIds, localIds);
expect(result, localIds.length);
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
verify(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
verify(() => localAssetRepository.delete(localIds)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('locks remote assets without touching local media when there are no local copies', () async {
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
final result = await sut.moveToLockFolder(remoteIds, const []);
expect(result, 0);
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash')));
});
test('returns zero when local deletion is cancelled', () async {
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => <String>[]);
final result = await sut.moveToLockFolder(remoteIds, localIds);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('returns the number of local copies deleted from a partial result', () async {
const deletedIds = ['l1'];
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => deletedIds);
when(() => localAssetRepository.delete(deletedIds)).thenAnswer((_) async {});
final result = await sut.moveToLockFolder(remoteIds, localIds);
expect(result, deletedIds.length);
verify(() => localAssetRepository.delete(deletedIds)).called(1);
});
});
}

View File

@@ -1,8 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
@@ -20,11 +23,18 @@ void main() {
context.dispose();
});
List<Override> selection(Set<BaseAsset> assets) => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
),
];
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()])),
const AssetDebugAction(display: ActionDisplay.iconButton),
overrides: selection({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsOneWidget);
@@ -33,9 +43,8 @@ void main() {
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
const AssetDebugAction(display: ActionDisplay.iconButton),
overrides: selection({RemoteAssetFactory.create(), RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -45,7 +54,8 @@ void main() {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
const AssetDebugAction(display: ActionDisplay.iconButton),
overrides: selection({RemoteAssetFactory.create()}),
);
expect(find.byType(ImmichIconButton), findsNothing);

View File

@@ -1,7 +1,10 @@
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.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
@@ -24,28 +27,34 @@ void main() {
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
List<Override> selection(Set<BaseAsset> assets) => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})),
),
];
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({asset}),
);
verify(() => assetService.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]));
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({mine, theirs}),
);
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
});
@@ -54,25 +63,51 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({first, second}),
);
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('skips owned assets already favorited', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
await tester.pumpTestAction(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({stale, alreadyFavorite}),
);
verify(() => assetService.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(
context,
const FavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({owned()}),
);
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
group('UnfavoriteAction', () {
testWidgets('unfavorites the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(
context,
const UnfavoriteAction(display: ActionDisplay.iconButton),
overrides: selection({asset}),
);
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
});
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:mocktail/mocktail.dart';
@@ -31,7 +32,7 @@ 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(context, const PartnerAddAction(display: ActionDisplay.iconButton), overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
@@ -42,7 +43,7 @@ void main() {
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
const PartnerAddAction(),
const PartnerAddAction(display: ActionDisplay.iconButton),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
@@ -57,7 +58,7 @@ void main() {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
@@ -70,7 +71,7 @@ void main() {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, display: ActionDisplay.iconButton),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel

View File

@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
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';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
@override
Future<void> onAction(ActionScope scope) async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
}
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
),
),
];
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
container = ProviderScope.containerOf(innerContext, listen: false);
return const SizedBox.shrink();
},
),
overrides: overrides(),
);
return (scope, container);
}
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final inner = _FakeAction();
final (scope, container) = await pumpScope(tester);
await TimelineAction(action: inner).onAction(scope);
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
expect(container.read(multiSelectProvider).isEnabled, isFalse);
});
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
});
}

View File

@@ -17,12 +17,10 @@ void main() {
group('PartnerSharedByList', () {
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: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
@@ -39,9 +37,8 @@ void main() {
testWidgets('renders a remove action for each partner', (tester) async {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(action.icon), findsNWidgets(2));
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
});
});

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
@@ -98,10 +97,10 @@ extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
ActionWidget action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
await pumpTestWidget(context, action, overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}