Compare commits

..

5 Commits

Author SHA1 Message Date
shenlong-tanwen
4103309ffe refactor: constant actions 2026-07-17 21:04:02 +05:30
shenlong-tanwen
9419c8456c refactor: mobile upload action 2026-07-11 04:52:51 +05:30
shenlong-tanwen
23f26486a1 refactor: mobile tag and download actions 2026-07-10 05:46:04 +05:30
shenlong-tanwen
64607857f5 refactor: mobile share actions 2026-07-10 05:00:58 +05:30
shenlong-tanwen
f3dd742a1f refactor: action secondary handler 2026-07-10 04:34:41 +05:30
76 changed files with 1969 additions and 1927 deletions

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
@@ -28,14 +27,12 @@ class PartnerPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedByAsync = ref.watch(partnersStateProvider);
final scope = ActionScope.from(context, ref);
return Scaffold(
appBar: AppBar(
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: [ActionIconButtonWidget(action: PartnerAddAction(scope: scope))],
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
@@ -51,7 +48,6 @@ class _EmptyPartners extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final scope = ActionScope.from(context, ref);
return Padding(
padding: const .symmetric(horizontal: 16.0),
child: Column(
@@ -61,9 +57,9 @@ class _EmptyPartners extends ConsumerWidget {
padding: const .symmetric(vertical: 8),
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
),
Align(
const Align(
alignment: .center,
child: ActionButtonWidget(action: PartnerAddAction(scope: scope)),
child: ActionButtonWidget(action: PartnerAddAction()),
),
],
),
@@ -82,8 +78,6 @@ class PartnerSharedByList extends ConsumerWidget {
if (partners.isEmpty) {
return const _EmptyPartners();
}
final scope = ActionScope.from(context, ref);
return ListView.builder(
itemCount: partners.length,
itemBuilder: (_, index) {
@@ -93,7 +87,7 @@ class PartnerSharedByList extends ConsumerWidget {
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: ActionIconButtonWidget(
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
),
);
},

View File

@@ -1,34 +1,54 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
export 'package:immich_mobile/constants/enums.dart' show ActionSource;
extension ActionSourceAssets on ActionSource {
Iterable<BaseAsset> select(WidgetRef ref) => switch (this) {
.timeline => ref.watch(multiSelectProvider.select((s) => s.selectedAssets)),
.viewer => switch (ref.watch(assetViewerProvider.select((s) => s.currentAsset))) {
final a? => [a],
null => const [],
},
};
}
class ActionScope {
final BuildContext context;
final WidgetRef ref;
final Iterable<BaseAsset> assets;
final UserDto authUser;
final WidgetRef ref;
BuildContext get context => ref.context;
const ActionScope({required this.context, required this.ref, required this.authUser});
const ActionScope({required this.assets, required this.authUser, required this.ref});
factory ActionScope.from(BuildContext context, WidgetRef ref) {
static ActionScope of(WidgetRef ref, ActionSource? source) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
throw StateError('Auth user is not available in ActionScope');
}
return ActionScope(context: context, ref: ref, authUser: authUser);
return ActionScope(assets: source?.select(ref) ?? const [], authUser: authUser, ref: ref);
}
}
abstract class BaseAction {
final ActionScope scope;
const BaseAction();
// Return null if the action is not applicable in the given scope
WidgetAction? resolve(ActionScope scope);
}
class WidgetAction {
final IconData icon;
final String label;
final bool isVisible;
final Future<void> Function() onAction;
final Future<void> Function()? onSecondaryAction;
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
Future<void> onAction();
const WidgetAction({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
}

View File

@@ -7,23 +7,23 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
class _ActionWidgetScope {
final IconData icon;
final String label;
final FutureOr<void> Function() onAction;
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
}
typedef _ActionWidgetScope = ({
IconData icon,
String label,
FutureOr<void> Function() onAction,
FutureOr<void> Function()? onSecondaryAction,
});
class _ActionWidget extends ConsumerWidget {
final BaseAction action;
final ActionSource? source;
final Widget Function(_ActionWidgetScope context) builder;
const _ActionWidget({required this.action, required this.builder});
const _ActionWidget({required this.action, required this.builder, this.source});
Future<void> _onAction() async {
Future<void> _guard(Future<void> Function() handler) async {
try {
await action.onAction();
await handler();
} catch (error, stackTrace) {
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
@@ -31,60 +31,86 @@ class _ActionWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!action.isVisible) {
return const SizedBox.shrink();
if (action.resolve(ActionScope.of(ref, source)) case final resolved?) {
final onSecondaryAction = resolved.onSecondaryAction;
return builder((
icon: resolved.icon,
label: resolved.label,
onAction: () => _guard(resolved.onAction),
onSecondaryAction: onSecondaryAction == null ? null : () => _guard(onSecondaryAction),
));
}
return builder(.new(icon: action.icon, label: action.label, onAction: _onAction));
return const SizedBox.shrink();
}
}
class ActionIconButtonWidget extends StatelessWidget {
final BaseAction action;
final ActionSource? source;
final ImmichVariant variant;
const ActionIconButtonWidget({super.key, required this.action, this.variant = .ghost});
const ActionIconButtonWidget({super.key, required this.action, this.source, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
source: source,
builder: (ctx) =>
ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, onLongPress: ctx.onSecondaryAction, variant: variant),
);
}
class ActionButtonWidget extends StatelessWidget {
final BaseAction action;
final ActionSource? source;
final ImmichVariant variant;
const ActionButtonWidget({super.key, required this.action, this.variant = .ghost});
const ActionButtonWidget({super.key, required this.action, this.source, this.variant = .ghost});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
source: source,
builder: (ctx) => ImmichTextButton(
labelText: ctx.label,
icon: ctx.icon,
onPressed: ctx.onAction,
onLongPress: ctx.onSecondaryAction,
variant: variant,
),
);
}
class ActionColumnButtonWidget extends StatelessWidget {
final BaseAction action;
final ActionSource? source;
const ActionColumnButtonWidget({super.key, required this.action});
const ActionColumnButtonWidget({super.key, required this.action, this.source});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
source: source,
builder: (ctx) => ImmichColumnButton(
icon: ctx.icon,
label: ctx.label,
onPressed: ctx.onAction,
onLongPress: ctx.onSecondaryAction,
),
);
}
class ActionMenuItemWidget extends StatelessWidget {
final BaseAction action;
final ActionSource? source;
const ActionMenuItemWidget({super.key, required this.action});
const ActionMenuItemWidget({super.key, required this.action, this.source});
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
source: source,
builder: (ctx) => ImmichMenuItem(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}

View File

@@ -1,5 +1,4 @@
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';
@@ -7,41 +6,33 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class ArchiveAction extends BaseAction {
final List<String> assetIds;
final bool archive;
const ArchiveAction();
const ArchiveAction._({
required this.assetIds,
required this.archive,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :authUser, :assets) = scope;
final owned = AssetFilter(assets).owned(authUser.id);
factory ArchiveAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final archive = ownedAssets.archived(isArchived: false).isNotEmpty;
final assetIds = ownedAssets.archived(isArchived: !archive).map((asset) => asset.id).toList(growable: false);
final archive = owned.archived(isArchived: false).isNotEmpty;
final ids = owned.archived(isArchived: !archive).map((asset) => asset.id).toList(growable: false);
if (ids.isEmpty) {
return null;
}
return ArchiveAction._(
assetIds: assetIds,
archive: archive,
scope: scope,
return .new(
icon: archive ? Icons.archive_outlined : Icons.unarchive_outlined,
label: archive ? scope.context.t.archive : scope.context.t.unarchive,
isVisible: assetIds.isNotEmpty,
label: archive ? context.t.archive : context.t.unarchive,
onAction: () => _onAction(scope, ids, archive: archive),
);
}
@override
Future<void> onAction() async {
Future<void> _onAction(ActionScope scope, List<String> ids, {required bool archive}) async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(archive ? .archive : .timeline));
await ref.read(assetServiceProvider).update(ids, visibility: .some(archive ? .archive : .timeline));
final message = archive
? context.t.archive_action_prompt(count: assetIds.length)
: context.t.unarchive_action_prompt(count: assetIds.length);
? context.t.archive_action_prompt(count: ids.length)
: context.t.unarchive_action_prompt(count: ids.length);
ref.read(toastRepositoryProvider).success(message);
}
}

View File

@@ -1,46 +0,0 @@
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/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
class AssetActions {
final AssetDebugAction debug;
final FavoriteAction favorite;
final ArchiveAction archive;
final StackAction stack;
final LockAction lock;
final DeleteAction delete;
final CleanupLocalAction cleanup;
final EditDateTimeAction editDateTime;
final EditLocationAction editLocation;
const AssetActions({
required this.debug,
required this.favorite,
required this.archive,
required this.stack,
required this.lock,
required this.delete,
required this.cleanup,
required this.editDateTime,
required this.editLocation,
});
factory AssetActions.from(ActionScope scope, List<BaseAsset> assets) => .new(
debug: AssetDebugAction(assets: assets, scope: scope),
favorite: FavoriteAction(assets: assets, scope: scope),
archive: ArchiveAction(assets: assets, scope: scope),
stack: StackAction(assets: assets, scope: scope),
lock: LockAction(assets: assets, scope: scope),
delete: DeleteAction(assets: assets, scope: scope),
cleanup: CleanupLocalAction(assets: assets, scope: scope),
editDateTime: EditDateTimeAction(assets: assets, scope: scope),
editLocation: EditLocationAction(assets: assets, scope: scope),
);
}

View File

@@ -2,24 +2,28 @@ 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:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends BaseAction {
final List<BaseAsset> asset;
AssetDebugAction._({this.asset = const [], required super.scope, super.isVisible})
: super(icon: Icons.help_outline_rounded, label: scope.context.t.troubleshoot);
factory AssetDebugAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => AssetDebugAction._(
asset: assets.toList(growable: false),
scope: scope,
isVisible: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting) && assets.length == 1,
);
const AssetDebugAction();
@override
Future<void> onAction() async => unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: asset.first)));
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context) = scope;
final assets = scope.assets.toList(growable: false);
final enabled = ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
if (!enabled || assets.length != 1) {
return null;
}
return .new(
icon: Icons.help_outline_rounded,
label: context.t.troubleshoot,
onAction: () async => unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first))),
);
}
}

View File

@@ -7,19 +7,17 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastAction extends BaseAction {
const CastAction._({required super.scope, required super.icon, required super.label});
factory CastAction({required ActionScope scope}) {
final casting = scope.ref.watch(castProvider.select((state) => state.isCasting));
return CastAction._(
scope: scope,
icon: casting ? Icons.cast_connected_rounded : Icons.cast_rounded,
label: scope.context.t.cast,
);
}
const CastAction();
@override
Future<void> onAction() async {
unawaited(showDialog(context: scope.context, builder: (_) => const CastDialog()));
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context) = scope;
final casting = ref.watch(castProvider.select((state) => state.isCasting));
return .new(
icon: casting ? Icons.cast_connected_rounded : Icons.cast_rounded,
label: context.t.cast,
onAction: () async => unawaited(showDialog(context: context, builder: (_) => const CastDialog())),
);
}
}

View File

@@ -1,4 +1,5 @@
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/extensions/platform_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -12,20 +13,11 @@ import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class DeleteAction extends BaseAction {
final List<String> localIds;
final List<String> remoteIds;
final bool trash;
const DeleteAction();
DeleteAction._({
required this.localIds,
required this.remoteIds,
required super.scope,
required this.trash,
super.isVisible,
}) : super(icon: Icons.delete_outline, label: trash ? scope.context.t.trash : scope.context.t.delete);
factory DeleteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ActionScope(:ref) = scope;
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :authUser, :assets, :context) = scope;
final localIds = <String>[];
final ownedRemote = <RemoteAsset>[];
@@ -33,28 +25,32 @@ class DeleteAction extends BaseAction {
if (asset.localId case final id?) {
localIds.add(id);
}
if (asset case final RemoteAsset remote when remote.ownerId == scope.authUser.id) {
if (asset case final RemoteAsset remote when remote.ownerId == authUser.id) {
ownedRemote.add(remote);
}
}
final remoteIds = ownedRemote.map((asset) => asset.id).toList(growable: false);
if (remoteIds.isEmpty && localIds.isEmpty) {
return null;
}
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
// Assets already in trash or in locked page should be permanently deleted irrespective of the trash feature being enabled
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
final permanentDelete = ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
final trash = !permanentDelete && ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
return ._(
localIds: localIds,
remoteIds: remoteIds,
trash: trash,
scope: scope,
isVisible: remoteIds.isNotEmpty || localIds.isNotEmpty,
return .new(
icon: Icons.delete_outline,
label: trash ? context.t.trash : context.t.delete,
onAction: () => _onAction(scope, localIds: localIds, remoteIds: remoteIds, trash: trash),
);
}
@override
Future<void> onAction() async {
Future<void> _onAction(
ActionScope scope, {
required List<String> localIds,
required List<String> remoteIds,
required bool trash,
}) async {
final ActionScope(:ref, :context) = scope;
final toast = ref.read(toastRepositoryProvider);
@@ -66,7 +62,7 @@ class DeleteAction extends BaseAction {
return;
}
final count = await cleanupLocalAssets(assetIds: localIds, scope: scope);
final count = await cleanupLocalAssets(ref, assetIds: localIds);
if (!context.mounted || count <= 0) {
return;
}
@@ -80,7 +76,7 @@ class DeleteAction extends BaseAction {
// TODO(shenlong): Handle the native prompt response and skip deleting trash when user cancels the prompt
if (trash) {
if (localIds.isNotEmpty) {
await cleanupLocalAssets(assetIds: localIds, scope: scope);
await cleanupLocalAssets(ref, assetIds: localIds);
if (!context.mounted) {
return;
}
@@ -105,7 +101,7 @@ class DeleteAction extends BaseAction {
// Perform server deletion first so we don't remove the only local copy if the server delete fails
await ref.read(assetServiceProvider).delete(remoteIds);
if (localIds.isNotEmpty && context.mounted) {
await cleanupLocalAssets(assetIds: localIds, scope: scope, requestPrompt: false);
await cleanupLocalAssets(ref, assetIds: localIds, requestPrompt: false);
}
if (!context.mounted) {
@@ -117,24 +113,26 @@ class DeleteAction extends BaseAction {
}
class CleanupLocalAction extends BaseAction {
final List<String> assetIds;
CleanupLocalAction._({required this.assetIds, required super.scope})
: super(
icon: Icons.no_cell_outlined,
label: scope.context.t.control_bottom_app_bar_delete_from_local,
isVisible: assetIds.isNotEmpty,
);
factory CleanupLocalAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => ._(
assetIds: AssetFilter(assets).backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false),
scope: scope,
);
const CleanupLocalAction();
@override
Future<void> onAction() async {
WidgetAction? resolve(ActionScope scope) {
final ids = AssetFilter(scope.assets).backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false);
if (ids.isEmpty) {
return null;
}
return .new(
icon: Icons.no_cell_outlined,
label: scope.context.t.control_bottom_app_bar_delete_from_local,
onAction: () => _onAction(scope, ids),
);
}
Future<void> _onAction(ActionScope scope, List<String> ids) async {
final ActionScope(:ref, :context) = scope;
final count = await cleanupLocalAssets(assetIds: assetIds, scope: scope);
final count = await cleanupLocalAssets(ref, assetIds: ids);
if (!context.mounted || count <= 0) {
return;
}
@@ -143,12 +141,8 @@ class CleanupLocalAction extends BaseAction {
}
@visibleForTesting
Future<int> cleanupLocalAssets({
required List<String> assetIds,
required ActionScope scope,
bool requestPrompt = true,
}) async {
final ActionScope(:ref, :context) = scope;
Future<int> cleanupLocalAssets(WidgetRef ref, {required List<String> assetIds, bool requestPrompt = true}) async {
final context = ref.context;
/// OS prompts on iOS & Android (without MANAGE_MEDIA)
/// Custom prompt on Android (with MANAGE_MEDIA)

View File

@@ -0,0 +1,37 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class DownloadAction extends BaseAction {
const DownloadAction();
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :assets) = scope;
final remoteAssets = AssetFilter(assets).remote().toList(growable: false);
if (remoteAssets.isEmpty) {
return null;
}
return .new(
icon: Icons.download,
label: context.t.download,
onAction: () async {
final backgroundSync = ref.read(backgroundSyncProvider);
await ref.read(downloadRepositoryProvider).downloadAllAssets(remoteAssets);
unawaited(
Future.delayed(const Duration(seconds: 1), () async {
await backgroundSync.syncLocal();
await backgroundSync.hashAssets();
}),
);
},
);
}
}

View File

@@ -17,26 +17,24 @@ import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_mobile/utils/semver.dart';
class EditAssetAction extends BaseAction {
final Iterable<RemoteAsset> assets;
EditAssetAction._({required this.assets, required super.scope, super.isVisible})
: super(icon: Icons.tune, label: scope.context.t.edit);
factory EditAssetAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final editable = AssetFilter(
assets,
).owned(scope.authUser.id).where((asset) => asset.isEditable).toList(growable: false);
final isSupported = scope.ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
return EditAssetAction._(assets: editable, scope: scope, isVisible: isSupported && editable.length == 1);
}
const EditAssetAction();
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :authUser, :assets, :context) = scope;
final editable = AssetFilter(assets).owned(authUser.id).where((asset) => asset.isEditable).toList(growable: false);
final supported = ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
if (!supported || editable.length != 1) {
return null;
}
return .new(icon: Icons.tune, label: context.t.edit, onAction: () => _onAction(scope, editable.first));
}
Future<void> _onAction(ActionScope scope, RemoteAsset asset) async {
final ActionScope(:ref, :context) = scope;
// TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route
final asset = assets.first;
final repository = ref.read(remoteAssetRepositoryProvider);
final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait;
if (exif == null || !context.mounted) {

View File

@@ -10,26 +10,26 @@ import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
class EditDateTimeAction extends BaseAction {
final List<String> assetIds;
final RemoteAsset? origin;
const EditDateTimeAction();
EditDateTimeAction._({required this.assetIds, required this.origin, required super.scope, super.isVisible})
: super(icon: Icons.edit_calendar_outlined, label: scope.context.t.control_bottom_app_bar_edit_time);
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:authUser, :assets, :context) = scope;
final owned = AssetFilter(assets).owned(authUser.id);
final ids = owned.map((asset) => asset.id).toList(growable: false);
if (ids.isEmpty) {
return null;
}
factory EditDateTimeAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final owned = AssetFilter(assets).owned(scope.authUser.id);
return EditDateTimeAction._(
assetIds: owned.map((asset) => asset.id).toList(growable: false),
origin: owned.firstOrNull,
scope: scope,
isVisible: owned.isNotEmpty,
return .new(
icon: Icons.edit_calendar_outlined,
label: context.t.control_bottom_app_bar_edit_time,
onAction: () => _onAction(scope, ids, owned.firstOrNull),
);
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
Future<void> _onAction(ActionScope scope, List<String> ids, RemoteAsset? origin) async {
final ActionScope(:ref, :context) = scope;
DateTime? initialDate;
String? timeZone;
@@ -63,15 +63,14 @@ class EditDateTimeAction extends BaseAction {
return;
}
await save(dateTime);
await save(scope, ids, dateTime);
}
@visibleForTesting
Future<void> save(String dateTime) async {
final ActionScope(:context, :ref) = scope;
await ref.read(assetServiceProvider).update(assetIds, dateTime: .some(dateTime));
Future<void> save(ActionScope scope, List<String> ids, String dateTime) async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(ids, dateTime: .some(dateTime));
ref.invalidate(assetExifProvider);
ref.read(toastRepositoryProvider).success(context.t.edit_date_and_time_action_prompt(count: assetIds.length));
ref.read(toastRepositoryProvider).success(context.t.edit_date_and_time_action_prompt(count: ids.length));
}
}

View File

@@ -9,37 +9,29 @@ import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class EditLocationAction extends BaseAction {
final List<String> assetIds;
final RemoteAsset? origin;
const EditLocationAction();
const EditLocationAction._({
required this.assetIds,
required this.origin,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory EditLocationAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:authUser, :assets, :context) = scope;
final owned = assets
.whereType<RemoteAsset>()
.where((asset) => asset.ownerId == scope.authUser.id)
.where((asset) => asset.ownerId == authUser.id)
.toList(growable: false);
final ids = owned.map((asset) => asset.id).toList(growable: false);
if (ids.isEmpty) {
return null;
}
return EditLocationAction._(
assetIds: owned.map((asset) => asset.id).toList(growable: false),
origin: owned.length == 1 ? owned.first : null,
scope: scope,
return .new(
icon: Icons.edit_location_alt_outlined,
label: scope.context.t.control_bottom_app_bar_edit_location,
isVisible: owned.isNotEmpty,
label: context.t.control_bottom_app_bar_edit_location,
onAction: () => _onAction(scope, ids, owned.length == 1 ? owned.first : null),
);
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
Future<void> _onAction(ActionScope scope, List<String> ids, RemoteAsset? origin) async {
final ActionScope(:ref, :context) = scope;
LatLng? initialLatLng;
final seed = origin;
@@ -59,15 +51,14 @@ class EditLocationAction extends BaseAction {
return;
}
await save(location);
await save(scope, ids, location);
}
@visibleForTesting
Future<void> save(LatLng location) async {
final ActionScope(:context, :ref) = scope;
await ref.read(assetServiceProvider).update(assetIds, location: .some(location));
Future<void> save(ActionScope scope, List<String> ids, LatLng location) async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(ids, location: .some(location));
ref.invalidate(assetExifProvider);
ref.read(toastRepositoryProvider).success(context.t.edit_location_action_prompt(count: assetIds.length));
ref.read(toastRepositoryProvider).success(context.t.edit_location_action_prompt(count: ids.length));
}
}

View File

@@ -1,5 +1,4 @@
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';
@@ -7,41 +6,33 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class FavoriteAction extends BaseAction {
final List<String> assetIds;
final bool favorite;
const FavoriteAction();
const FavoriteAction._({
required this.assetIds,
required this.favorite,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :authUser, :assets) = scope;
factory FavoriteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final favorite = ownedAssets.favorite(isFavorite: false).isNotEmpty;
final assetIds = ownedAssets.favorite(isFavorite: !favorite).map((asset) => asset.id).toList(growable: false);
final owned = AssetFilter(assets).owned(authUser.id);
final favorite = owned.favorite(isFavorite: false).isNotEmpty;
final ids = owned.favorite(isFavorite: !favorite).map((asset) => asset.id).toList(growable: false);
if (ids.isEmpty) {
return null;
}
return FavoriteAction._(
assetIds: assetIds,
favorite: favorite,
scope: scope,
return .new(
icon: favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
label: favorite ? scope.context.t.favorite : scope.context.t.unfavorite,
isVisible: assetIds.isNotEmpty,
label: favorite ? context.t.favorite : context.t.unfavorite,
onAction: () => _onAction(scope, ids, favorite: favorite),
);
}
@override
Future<void> onAction() async {
Future<void> _onAction(ActionScope scope, List<String> ids, {required bool favorite}) async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(assetIds, isFavorite: .some(favorite));
await ref.read(assetServiceProvider).update(ids, isFavorite: .some(favorite));
final message = favorite
? context.t.favorite_action_prompt(count: assetIds.length)
: context.t.unfavorite_action_prompt(count: assetIds.length);
? context.t.favorite_action_prompt(count: ids.length)
: context.t.unfavorite_action_prompt(count: ids.length);
ref.read(toastRepositoryProvider).success(message);
}
}

View File

@@ -1,5 +1,4 @@
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';
@@ -7,41 +6,31 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class LockAction extends BaseAction {
final List<String> assetIds;
final bool lock;
const LockAction();
const LockAction._({
required this.assetIds,
required this.lock,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
@override
WidgetAction? resolve(ActionScope scope) {
final owned = AssetFilter(scope.assets).owned(scope.authUser.id);
final lock = owned.locked(isLocked: false).isNotEmpty;
final ids = owned.locked(isLocked: !lock).map((asset) => asset.id).toList(growable: false);
if (ids.isEmpty) {
return null;
}
factory LockAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final lock = ownedAssets.locked(isLocked: false).isNotEmpty;
final assetIds = ownedAssets.locked(isLocked: !lock).map((asset) => asset.id).toList(growable: false);
return LockAction._(
assetIds: assetIds,
lock: lock,
scope: scope,
return .new(
icon: lock ? Icons.lock_rounded : Icons.lock_open_rounded,
label: lock ? scope.context.t.move_to_locked_folder : scope.context.t.remove_from_locked_folder,
isVisible: assetIds.isNotEmpty,
onAction: () => _onAction(scope, ids, lock: lock),
);
}
@override
Future<void> onAction() async {
Future<void> _onAction(ActionScope scope, List<String> ids, {required bool lock}) async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(lock ? .locked : .timeline));
await ref.read(assetServiceProvider).update(ids, visibility: .some(lock ? .locked : .timeline));
final message = lock
? scope.context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
: scope.context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
? context.t.move_to_lock_folder_action_prompt(count: ids.length)
: context.t.remove_from_lock_folder_action_prompt(count: ids.length);
ref.read(toastRepositoryProvider).success(message);
}
}

View File

@@ -6,26 +6,39 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserAction extends BaseAction {
final String remoteId;
final TimelineOrigin origin;
OpenInBrowserAction({required this.remoteId, required this.origin, required super.scope})
: super(icon: Icons.open_in_browser, label: scope.context.t.open_in_browser);
const OpenInBrowserAction({required this.origin});
@override
Future<void> onAction() async {
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :assets) = scope;
final originPath = switch (origin) {
.favorite => '/favorites',
.trash => '/trash',
.archive => '/archive',
_ => '',
};
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: .externalApplication);
if (assets.isEmpty) {
return null;
}
final remoteId = assets.first.remoteId;
if (remoteId == null) {
return null;
}
return .new(
icon: Icons.open_in_browser,
label: context.t.open_in_browser,
onAction: () async {
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
final originPath = switch (origin) {
.favorite => '/favorites',
.trash => '/trash',
.archive => '/archive',
_ => '',
};
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: .externalApplication);
}
},
);
}
}

View File

@@ -9,11 +9,14 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
PartnerAddAction({required super.scope}) : super(icon: Icons.person_add_rounded, label: scope.context.t.add_partner);
const PartnerAddAction();
@override
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
WidgetAction? resolve(ActionScope scope) =>
WidgetAction(icon: Icons.person_add_rounded, label: scope.context.t.add_partner, onAction: () => _add(scope));
Future<void> _add(ActionScope scope) async {
final ActionScope(:ref, :context, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
return;
@@ -24,15 +27,17 @@ class PartnerAddAction extends BaseAction {
}
class PartnerRemoveAction extends BaseAction {
PartnerRemoveAction({required super.scope, required this.sharedWithId, required this.partnerName})
: super(icon: Icons.person_remove_rounded, label: scope.context.t.remove);
final String sharedWithId;
final String partnerName;
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
@override
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
WidgetAction? resolve(ActionScope scope) =>
WidgetAction(icon: Icons.person_remove_rounded, label: scope.context.t.remove, onAction: () => _remove(scope));
Future<void> _remove(ActionScope scope) async {
final ActionScope(:ref, :context, :authUser) = scope;
final confirmed = await showDialog<bool>(
context: context,

View File

@@ -1,5 +1,4 @@
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/album.provider.dart';
@@ -8,26 +7,27 @@ import 'package:immich_mobile/utils/asset_filter.dart';
class RemoveFromAlbumAction extends BaseAction {
final String albumId;
final List<String> assetIds;
RemoveFromAlbumAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
: super(icon: Icons.remove_circle_outline, label: scope.context.t.remove_from_album);
factory RemoveFromAlbumAction({
required Iterable<BaseAsset> assets,
required String albumId,
required ActionScope scope,
}) {
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
return RemoveFromAlbumAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.isNotEmpty);
}
const RemoveFromAlbumAction({required this.albumId});
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :assets) = scope;
final ids = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
if (ids.isEmpty) {
return null;
}
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: assetIds);
return .new(
icon: Icons.remove_circle_outline,
label: context.t.remove_from_album,
onAction: () => _onAction(scope, ids),
);
}
Future<void> _onAction(ActionScope scope, List<String> ids) async {
final ActionScope(:ref, :context) = scope;
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: ids);
ref.read(toastRepositoryProvider).success(context.t.remove_from_album_action_prompt(count: count));
}
}

View File

@@ -1,5 +1,4 @@
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';
@@ -7,23 +6,23 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class RestoreAction extends BaseAction {
final List<String> assetIds;
RestoreAction._({required super.scope, required this.assetIds, super.isVisible})
: super(icon: Icons.history_rounded, label: scope.context.t.restore);
factory RestoreAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final assetIds = AssetFilter(
assets,
).owned(scope.authUser.id).trashed().map((asset) => asset.id).toList(growable: false);
return RestoreAction._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
}
const RestoreAction();
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).restoreTrash(assetIds);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length));
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :authUser, :assets) = scope;
final ids = AssetFilter(assets).owned(authUser.id).trashed().map((asset) => asset.id).toList(growable: false);
if (ids.isEmpty) {
return null;
}
return .new(
icon: Icons.history_rounded,
label: context.t.restore,
onAction: () async {
await ref.read(assetServiceProvider).restoreTrash(ids);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: ids.length));
},
);
}
}

View File

@@ -1,5 +1,4 @@
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/album.provider.dart';
@@ -8,25 +7,27 @@ import 'package:immich_mobile/utils/asset_filter.dart';
class SetAlbumCoverAction extends BaseAction {
final String albumId;
final List<String> assetIds;
SetAlbumCoverAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
: super(icon: Icons.image_outlined, label: scope.context.t.set_as_album_cover);
factory SetAlbumCoverAction({
required Iterable<BaseAsset> assets,
required String albumId,
required ActionScope scope,
}) {
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
return SetAlbumCoverAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.length == 1);
}
const SetAlbumCoverAction({required this.albumId});
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :assets) = scope;
final ids = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
if (ids.length != 1) {
return null;
}
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetIds.first);
return .new(
icon: Icons.image_outlined,
label: context.t.set_as_album_cover,
onAction: () => _onAction(scope, ids.first),
);
}
Future<void> _onAction(ActionScope scope, String assetId) async {
final ActionScope(:ref, :context) = scope;
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetId);
ref.read(toastRepositoryProvider).success(context.t.album_cover_updated);
}
}

View File

@@ -2,17 +2,26 @@ 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:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/routing/router.dart';
class SetProfilePictureAction extends BaseAction {
final BaseAsset asset;
SetProfilePictureAction({required this.asset, required super.scope})
: super(icon: Icons.account_circle_outlined, label: scope.context.t.set_as_profile_picture);
const SetProfilePictureAction();
@override
Future<void> onAction() async => unawaited(scope.context.pushRoute(ProfilePictureCropRoute(asset: asset)));
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :assets) = scope;
if (assets.isEmpty) {
return null;
}
final asset = assets.first;
return .new(
icon: Icons.account_circle_outlined,
label: context.t.set_as_profile_picture,
onAction: () async => unawaited(context.pushRoute(ProfilePictureCropRoute(asset: asset))),
);
}
}

View File

@@ -0,0 +1,170 @@
import 'dart:async';
import 'package:flutter/material.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/extensions/platform_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
class ShareAction extends BaseAction {
const ShareAction();
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :assets) = scope;
if (assets.isEmpty) {
return null;
}
return .new(
icon: CurrentPlatform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: scope.context.t.share,
onAction: () => _share(scope, assets, scope.ref.read(appConfigProvider).share.fileType),
onSecondaryAction: () => _promptQualityAndShare(scope, assets),
);
}
Future<void> _promptQualityAndShare(ActionScope scope, Iterable<BaseAsset> assets) async {
final ActionScope(:ref, :context) = scope;
// only show preview option when at least one of the assets is not a video
// we cant share previews of videos
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
final fileType = await showDialog<ShareAssetType>(
context: context,
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
useRootNavigator: false,
);
if (fileType == null || !context.mounted) {
return;
}
await ref.read(settingsProvider).write(.shareFileType, fileType);
if (!context.mounted) {
return;
}
await _share(scope, assets, fileType);
}
Future<void> _share(ActionScope scope, Iterable<BaseAsset> assets, ShareAssetType fileType) async {
final ActionScope(:ref, :context) = scope;
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(null);
await showDialog(
context: context,
barrierDismissible: false,
useRootNavigator: false,
builder: (dialogContext) {
void finish({required bool failed}) {
if (cancelCompleter.isCompleted || !dialogContext.mounted) {
return;
}
if (failed) {
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
}
dialogContext.pop();
}
unawaited(
ref
.read(assetMediaRepositoryProvider)
.shareAssets(
assets.toList(growable: false),
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
.then<void>(
(count) => finish(failed: count == 0 && assets.isNotEmpty),
onError: (_) => finish(failed: true),
),
);
// Show download progress with a "Preparing" message
return _SharePreparingDialog(progress: progress);
},
).then((_) {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
progress.dispose();
});
}
}
class _SharePreparingDialog extends StatelessWidget {
final ValueNotifier<double?> progress;
const _SharePreparingDialog({required this.progress});
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: .min,
children: [
Container(margin: const .only(bottom: 12), child: Text(context.t.share_dialog_preparing)),
SizedBox(
width: 240,
child: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (context, value, _) {
final percent = value == null ? null : (value * 100).clamp(0, 100);
return Column(
mainAxisSize: .min,
children: [
LinearProgressIndicator(value: value, minHeight: 8.0),
if (percent != null)
Container(margin: const .only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
],
);
},
),
),
],
),
);
}
}
class _ShareFileTypeDialog extends StatelessWidget {
final bool showPreview;
const _ShareFileTypeDialog({this.showPreview = true});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(context.t.select_quality),
contentPadding: const .symmetric(vertical: 8),
content: Column(
mainAxisSize: .min,
children: [
ListTile(
leading: const Icon(Icons.high_quality_rounded),
title: Text(context.t.share_original),
onTap: () => context.pop(ShareAssetType.original),
),
if (showPreview)
ListTile(
leading: const Icon(Icons.photo_size_select_large_rounded),
title: Text(context.t.share_preview),
onTap: () => context.pop(ShareAssetType.preview),
),
],
),
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
);
}
}

View File

@@ -0,0 +1,28 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class ShareLinkAction extends BaseAction {
const ShareLinkAction();
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :assets) = scope;
final remoteIds = AssetFilter(assets).remote().map((asset) => asset.id).toList(growable: false);
if (remoteIds.isEmpty) {
return null;
}
return .new(
icon: Icons.link_rounded,
label: context.t.share_link,
onAction: () async => unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds))),
);
}
}

View File

@@ -4,40 +4,50 @@ 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:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SimilarPhotosAction extends BaseAction {
final String assetId;
SimilarPhotosAction({required this.assetId, required super.scope})
: super(icon: Icons.compare, label: scope.context.t.view_similar_photos);
const SimilarPhotosAction();
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :assets) = scope;
ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider);
if (assets.isEmpty) {
return null;
}
final asset = assets.first;
if (asset is! RemoteAsset) {
return null;
}
ref.read(searchPreFilterProvider.notifier)
..clear()
..setFilter(
SearchFilter(
assetId: assetId,
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
);
return .new(
icon: Icons.compare,
label: context.t.view_similar_photos,
onAction: () async {
ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider);
unawaited(context.navigateTo(const DriftSearchRoute()));
ref.read(searchPreFilterProvider.notifier)
..clear()
..setFilter(
.new(
assetId: asset.id,
people: {},
location: .new(),
camera: .new(),
date: .new(),
display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: .new(),
mediaType: .other,
),
);
unawaited(context.navigateTo(const DriftSearchRoute()));
},
);
}
}

View File

@@ -8,11 +8,16 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowAction extends BaseAction {
SlideshowAction({required super.scope}) : super(icon: Icons.slideshow, label: scope.context.t.slideshow);
const SlideshowAction();
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
unawaited(context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))));
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context) = scope;
return .new(
icon: Icons.slideshow,
label: context.t.slideshow,
onAction: () async =>
unawaited(context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)))),
);
}
}

View File

@@ -1,5 +1,4 @@
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';
@@ -7,50 +6,36 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class StackAction extends BaseAction {
final List<String> assetIds;
final List<String> stackIds;
final bool stack;
const StackAction._({
required this.assetIds,
required this.stackIds,
required this.stack,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory StackAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
// Stack when any owned asset is not yet stacked; otherwise unstack them all.
final stack = ownedAssets.stacked(isStacked: false).isNotEmpty;
final assetIds = ownedAssets.map((asset) => asset.id).toList(growable: false);
final stackIds = ownedAssets.map((asset) => asset.stackId).nonNulls.toList(growable: false);
return StackAction._(
assetIds: assetIds,
stackIds: stackIds,
stack: stack,
scope: scope,
icon: stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined,
label: stack ? scope.context.t.stack : scope.context.t.unstack,
isVisible: stack ? assetIds.length > 1 : stackIds.isNotEmpty,
);
}
const StackAction();
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
if (stack) {
await ref.read(assetServiceProvider).stack(scope.authUser.id, assetIds);
} else {
await ref.read(assetServiceProvider).unstack(stackIds);
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :authUser, :assets) = scope;
final owned = AssetFilter(assets).owned(authUser.id);
// Stack when any owned asset is not yet stacked; otherwise unstack them all.
final stack = owned.stacked(isStacked: false).isNotEmpty;
final assetIds = owned.map((asset) => asset.id).toList(growable: false);
final stackIds = owned.map((asset) => asset.stackId).nonNulls.toList(growable: false);
if (stack ? assetIds.length <= 1 : stackIds.isEmpty) {
return null;
}
final message = stack
? context.t.stacked_assets_count(count: assetIds.length)
: context.t.unstacked_assets_count(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
return .new(
icon: stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined,
label: stack ? context.t.stack : context.t.unstack,
onAction: () async {
if (stack) {
await ref.read(assetServiceProvider).stack(authUser.id, assetIds);
} else {
await ref.read(assetServiceProvider).unstack(stackIds);
}
final message = stack
? context.t.stacked_assets_count(count: assetIds.length)
: context.t.unstacked_assets_count(count: assetIds.length);
ref.read(toastRepositoryProvider).success(stack ? message : message);
},
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
class TagAction extends BaseAction {
const TagAction();
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:ref, :context, :authUser, :assets) = scope;
final assetIds = AssetFilter(assets).owned(authUser.id).map((asset) => asset.id).toList(growable: false);
if (assetIds.isEmpty) {
return null;
}
return .new(
icon: Icons.sell_outlined,
label: context.t.control_bottom_app_bar_add_tags,
onAction: () async {
final results = await showTagPickerModal(context: context);
if (results == null) {
return;
}
await applyTags(scope, assetIds, selected: results.$1, created: results.$2);
},
);
}
@visibleForTesting
Future<void> applyTags(
ActionScope scope,
List<String> assetIds, {
required Set<String> selected,
required Set<String> created,
}) async {
final ActionScope(:ref, :context) = scope;
final tagService = ref.read(tagServiceProvider);
final tagIds = {...selected};
if (created.isNotEmpty) {
final tags = await tagService.upsertTags(created.toList());
tagIds.addAll(tags.map((tag) => tag.id));
}
if (tagIds.isEmpty) {
return;
}
final count = await tagService.bulkTagAssets(assetIds, tagIds.toList());
ref.invalidate(tagProvider);
ref.read(toastRepositoryProvider).success(context.t.tagged_assets(count: count));
}
}

View File

@@ -1,15 +1,35 @@
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
/// Decorates an action so the multi-select is cleared after it runs.
class TimelineAction extends BaseAction {
final BaseAction action;
TimelineAction({required this.action})
: super(scope: action.scope, icon: action.icon, label: action.label, isVisible: action.isVisible);
const TimelineAction({required this.action});
@override
Future<void> onAction() async {
await action.onAction();
scope.ref.read(multiSelectProvider.notifier).reset();
WidgetAction? resolve(ActionScope scope) {
final resolved = action.resolve(scope);
if (resolved == null) {
return null;
}
void reset() => scope.ref.read(multiSelectProvider.notifier).reset();
final onSecondaryAction = resolved.onSecondaryAction;
return WidgetAction(
icon: resolved.icon,
label: resolved.label,
onAction: () async {
await resolved.onAction();
reset();
},
onSecondaryAction: onSecondaryAction == null
? null
: () async {
await onSecondaryAction();
reset();
},
);
}
}

View File

@@ -0,0 +1,142 @@
import 'dart:async';
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/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
class UploadAction extends BaseAction {
final ActionSource source;
const UploadAction({required this.source});
@override
WidgetAction? resolve(ActionScope scope) {
final ActionScope(:context, :assets) = scope;
final localAssets = AssetFilter(assets).backedUp(isBackedUp: false).local().toList(growable: false);
if (localAssets.isEmpty) {
return null;
}
return WidgetAction(
icon: Icons.backup_outlined,
label: context.t.upload,
onAction: () => _onAction(scope, localAssets),
);
}
Future<void> _onAction(ActionScope scope, List<LocalAsset> assets) async {
final ActionScope(:ref, :context) = scope;
if (source != ActionSource.viewer) {
await upload(scope, assets);
return;
}
var isDialogOpen = true;
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _UploadProgressDialog(),
).whenComplete(() => isDialogOpen = false),
);
await upload(scope, assets);
if (isDialogOpen && context.mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
}
@visibleForTesting
Future<void> upload(ActionScope scope, List<LocalAsset> assets) async {
final ActionScope(:ref, :context) = scope;
final progress = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final uploaded = <String>{};
final failed = <String>{};
for (final asset in assets) {
progress.setProgress(asset.id, 0.0);
}
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadManual(
assets,
cancelToken: cancelToken,
callbacks: UploadCallbacks(
onProgress: (id, _, bytes, total) => progress.setProgress(id, total > 0 ? bytes / total : 0.0),
onSuccess: (id, _) {
uploaded.add(id);
progress.remove(id);
},
onError: (id, _) {
failed.add(id);
progress.setError(id);
},
),
);
} finally {
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
}
final uploadedCount = uploaded.difference(failed).length;
final wasCancelled = cancelToken.isCompleted;
if (!wasCancelled && (uploadedCount != assets.length || failed.isNotEmpty)) {
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
}
unawaited(Future.delayed(const Duration(seconds: 2), progress.clear));
}
}
class _UploadProgressDialog extends ConsumerWidget {
const _UploadProgressDialog();
@override
Widget build(BuildContext context, WidgetRef ref) {
final progressMap = ref.watch(assetUploadProgressProvider);
// Calculate overall progress from all assets
final values = progressMap.values.where((v) => v >= 0).toList();
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
final hasError = progressMap.values.any((v) => v < 0);
final percentage = (progress * 100).toInt();
return AlertDialog(
title: Text(context.t.uploading),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasError)
const Icon(Icons.error_outline, color: Colors.red, size: 48)
else
CircularProgressIndicator(value: progress > 0 ? progress : null),
const SizedBox(height: 16),
Text(hasError ? 'Error' : '$percentage%'),
],
),
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context, rootNavigator: true).pop();
},
labelText: context.t.cancel,
),
],
);
}
}

View File

@@ -1,7 +1,6 @@
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/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -47,7 +46,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final scope = ActionScope.from(context, ref);
return [
Padding(
@@ -67,12 +65,8 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
ActionMenuItemWidget(
action: ArchiveAction(assets: [asset], scope: scope),
),
ActionMenuItemWidget(
action: LockAction(assets: [asset], scope: scope),
),
const ActionMenuItemWidget(source: .viewer, action: ArchiveAction()),
const ActionMenuItemWidget(source: .viewer, action: LockAction()),
],
];
}

View File

@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BulkTagAssetsActionButton extends ConsumerWidget {
final ActionSource source;
const BulkTagAssetsActionButton({super.key, required this.source});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
final result = await ref.read(actionProvider.notifier).tagAssets(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: result.success
? 'tagged_assets'.t(context: context, args: {'count': result.count.toString()})
: 'errors.failed_to_tag_assets'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.sell_outlined,
label: "control_bottom_app_bar_add_tags".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -1,47 +0,0 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) {
return;
}
try {
await ref.read(actionProvider.notifier).downloadAll(source);
Future.delayed(const Duration(seconds: 1), () async {
await backgroundSyncManager.syncLocal();
await backgroundSyncManager.hashAssets();
});
} finally {
ref.read(multiSelectProvider.notifier).reset();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final backgroundManager = ref.watch(backgroundSyncProvider);
return BaseActionButton(
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
);
}
}

View File

@@ -1,200 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class _SharePreparingDialog extends StatelessWidget {
final ValueNotifier<double?> progress;
const _SharePreparingDialog({required this.progress});
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(margin: const EdgeInsets.only(bottom: 12), child: const Text('share_dialog_preparing').tr()),
SizedBox(
width: 240,
child: ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (context, value, _) {
final percent = value == null ? null : (value * 100).clamp(0, 100);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
LinearProgressIndicator(value: value, minHeight: 8.0),
if (percent != null)
Container(margin: const EdgeInsets.only(top: 8), child: Text('${percent.toStringAsFixed(0)}%')),
],
);
},
),
),
],
),
);
}
}
class _ShareFileTypeDialog extends StatelessWidget {
final bool showPreview;
const _ShareFileTypeDialog({this.showPreview = true});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(context.t.select_quality),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.high_quality_rounded),
title: Text(context.t.share_original),
onTap: () => context.pop(ShareAssetType.original),
),
if (showPreview)
ListTile(
leading: const Icon(Icons.photo_size_select_large_rounded),
title: Text(context.t.share_preview),
onTap: () => context.pop(ShareAssetType.preview),
),
],
),
actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel))],
);
}
}
class ShareActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Set<BaseAsset> _getSelectedAssets(WidgetRef ref) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
BaseAsset asset => {asset},
null => const {},
},
};
}
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final fileType = ref.read(appConfigProvider).share.fileType;
await _share(context, ref, fileType);
}
void _onLongPress(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
// only show preview option when at least one of the assets is not a video
// we cant share previews of videos
final assets = _getSelectedAssets(ref);
final showPreview = assets.isEmpty || assets.any((asset) => !asset.isVideo);
final fileType = await showDialog<ShareAssetType>(
context: context,
builder: (_) => _ShareFileTypeDialog(showPreview: showPreview),
useRootNavigator: false,
);
if (fileType == null || !context.mounted) {
return;
}
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
if (!context.mounted) {
return;
}
await _share(context, ref, fileType);
}
Future<void> _share(BuildContext context, WidgetRef ref, ShareAssetType fileType) async {
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
await showDialog(
context: context,
builder: (BuildContext buildContext) {
ref
.read(actionProvider.notifier)
.shareAssets(
source,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
.then((ActionResult result) {
if (cancelCompleter.isCompleted || !context.mounted) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (!result.success) {
ImmichToast.show(
context: context,
msg: context.t.scaffold_body_error_occurred,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
buildContext.pop();
});
// Show download progress with a "Preparing" message
return preparingDialog;
},
barrierDismissible: false,
useRootNavigator: false,
).then((_) {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
progress.dispose();
});
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: context.t.share,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
onLongPressed: () => _onLongPress(context, ref),
);
}
}

View File

@@ -1,33 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class ShareLinkActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
await ref.read(actionProvider.notifier).shareLink(source, context);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.link_rounded,
label: "share_link".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -1,152 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
class UploadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
if (assets.isEmpty) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
} else {
isUploadDialogOpen = true;
uploadDialogFuture =
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => _UploadProgressDialog(
onCancel: () {
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
}
var success = false;
if (!isTimeline && viewerIntentFilePath != null) {
final viewIntentService = ref.read(viewIntentServiceProvider);
viewIntentService.markUploadActive(viewerIntentFilePath);
var hasError = false;
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (_, _) {
hasError = true;
},
);
} finally {
await viewIntentService.markUploadInactive(viewerIntentFilePath);
}
success = !hasError;
} else {
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
success = result.success;
}
if (!isTimeline && context.mounted && isUploadDialogOpen) {
Navigator.of(context, rootNavigator: true).pop();
}
if (context.mounted && !success && !wasUploadCancelled) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.backup_outlined,
label: "upload".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
class _UploadProgressDialog extends ConsumerWidget {
final VoidCallback onCancel;
const _UploadProgressDialog({required this.onCancel});
@override
Widget build(BuildContext context, WidgetRef ref) {
final progressMap = ref.watch(assetUploadProgressProvider);
// Calculate overall progress from all assets
final values = progressMap.values.where((v) => v >= 0).toList();
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
final hasError = progressMap.values.any((v) => v < 0);
final percentage = (progress * 100).toInt();
return AlertDialog(
title: Text('uploading'.t(context: context)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasError)
const Icon(Icons.error_outline, color: Colors.red, size: 48)
else
CircularProgressIndicator(value: progress > 0 ? progress : null),
const SizedBox(height: 16),
Text(hasError ? 'Error' : '$percentage%'),
],
),
actions: [
ImmichTextButton(
onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
onCancel();
Navigator.of(context, rootNavigator: true).pop();
},
labelText: 'cancel'.t(context: context),
),
],
);
}
}

View File

@@ -3,7 +3,6 @@ import 'dart:async';
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/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -30,7 +29,7 @@ class DateTimeDetails extends ConsumerWidget {
final asset = this.asset;
final exifInfo = this.exifInfo;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final editDateTime = EditDateTimeAction(assets: [asset], scope: ActionScope.from(context, ref));
final editDateTime = const EditDateTimeAction().resolve(ActionScope.of(ref, .viewer));
return Column(
children: [
@@ -38,7 +37,7 @@ class DateTimeDetails extends ConsumerWidget {
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.labelLarge,
trailing: const Icon(Icons.edit, size: 18),
onTap: editDateTime.onAction,
onTap: editDateTime?.onAction,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
],

View File

@@ -64,7 +64,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
return const SizedBox.shrink();
}
final editLocation = EditLocationAction(assets: [asset], scope: ActionScope.from(context, ref));
final editLocation = const EditLocationAction().resolve(ActionScope.of(ref, .viewer));
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@@ -77,7 +77,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
title: 'location'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation.onAction,
onTap: editLocation?.onAction,
),
if (hasCoordinates)
Padding(
@@ -112,7 +112,7 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
color: context.primaryColor,
),
leading: const Icon(Icons.location_off),
onTap: editLocation.onAction,
onTap: editLocation?.onAction,
),
],
),

View File

@@ -1,20 +1,18 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/services/timeline.service.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.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
@@ -31,29 +29,26 @@ class ViewerBottomBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
final isTrashed = asset is RemoteAsset && asset.isTrashed;
final originalTheme = context.themeData;
final assets = [asset];
final scope = ActionScope.from(context, ref);
final restore = RestoreAction(assets: assets, scope: scope);
final delete = DeleteAction(assets: assets, scope: scope);
final editImage = EditAssetAction(assets: assets, scope: scope);
final actions = <Widget>[
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
const ShareActionButton(source: ActionSource.viewer),
final scope = ActionScope.of(ref, .viewer);
Widget? column(BaseAction action) =>
action.resolve(scope) == null ? null : ActionColumnButtonWidget(source: .viewer, action: action);
final actions = <Widget>[
?column(const RestoreAction()),
if (!isInLockedView) ...[
if (!isInTrash) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
if (!isTrashed) ...[
?column(const ShareAction()),
?column(const EditAssetAction()),
?column(const UploadAction(source: .viewer)),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (delete.isVisible) ActionColumnButtonWidget(action: delete),
],
];
?column(const DeleteAction()),
].toList();
return AnimatedSwitcher(
duration: Durations.short4,

View File

@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
return ImmichMenu(
consumeOutsideTap: true,

View File

@@ -4,7 +4,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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.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';
@@ -44,8 +43,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 scope = ActionScope.from(context, ref);
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -63,9 +60,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(
action: FavoriteAction(assets: assetForAction, scope: scope),
),
const ActionIconButtonWidget(source: .viewer, action: FavoriteAction()),
ViewerKebabMenu(originalTheme: originalTheme),
];

View File

@@ -1,19 +1,23 @@
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/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class ArchiveBottomSheet extends ConsumerStatefulWidget {
@@ -40,8 +44,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
@@ -66,30 +68,30 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...[
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
],
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
const ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
...const [
ArchiveAction(),
FavoriteAction(),
DownloadAction(),
DeleteAction(),
EditDateTimeAction(),
EditLocationAction(),
LockAction(),
StackAction(),
CleanupLocalAction(),
].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
],
slivers: [
const AddToAlbumHeader(),

View File

@@ -1,16 +1,20 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -57,29 +61,29 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...[
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
],
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
const ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
...const [
FavoriteAction(),
ArchiveAction(),
DownloadAction(),
DeleteAction(),
EditDateTimeAction(),
EditLocationAction(),
LockAction(),
StackAction(),
CleanupLocalAction(),
].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
],
slivers: multiselect.hasRemote
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]

View File

@@ -1,22 +1,26 @@
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/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/upload.action.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';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class GeneralBottomSheet extends ConsumerStatefulWidget {
@@ -43,11 +47,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
@@ -71,9 +70,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
@@ -81,24 +77,30 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...[
actions.debug,
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
],
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
const ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: AssetDebugAction()),
),
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
const ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
...const [
DownloadAction(),
DeleteAction(),
FavoriteAction(),
ArchiveAction(),
TagAction(),
EditDateTimeAction(),
EditLocationAction(),
LockAction(),
StackAction(),
CleanupLocalAction(),
UploadAction(source: .timeline),
].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
],
slivers: [
const AddToAlbumHeader(),

View File

@@ -1,18 +1,16 @@
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/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/upload.action.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';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
@@ -63,22 +61,19 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final scope = ActionScope.from(context, ref);
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
final actions = AssetActions.from(scope, assets);
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
...[
actions.delete,
actions.cleanup,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const UploadActionButton(source: ActionSource.timeline),
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
...const [DeleteAction(), CleanupLocalAction(), UploadAction(source: .timeline)].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
],
slivers: [
const AddToAlbumHeader(),

View File

@@ -1,36 +1,30 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class LockedFolderBottomSheet extends ConsumerWidget {
const LockedFolderBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scope = ActionScope.from(context, ref);
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
final actions = AssetActions.from(scope, assets);
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
...[
actions.delete,
LockAction(assets: assets, scope: scope),
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
...const [DownloadAction(), DeleteAction(), LockAction()].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
],
);
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
class PartnerDetailBottomSheet extends ConsumerWidget {
@@ -15,8 +16,11 @@ class PartnerDetailBottomSheet extends ConsumerWidget {
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: DownloadAction()),
),
],
);
}

View File

@@ -1,17 +1,22 @@
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -75,10 +80,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final scope = ActionScope.from(context, ref);
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = AssetActions.from(scope, assets);
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
@@ -86,35 +87,39 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
...[
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
actions.editDateTime,
actions.editLocation,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
const DownloadActionButton(source: ActionSource.timeline),
],
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
const ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
if (ownsAlbum)
ActionColumnButtonWidget(
action: TimelineAction(
action: RemoveFromAlbumAction(assets: assets, albumId: widget.album.id, scope: scope),
...const [ArchiveAction(), FavoriteAction()].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
const ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: DownloadAction()),
),
if (ownsAlbum)
...const [DeleteAction(), EditDateTimeAction(), EditLocationAction(), LockAction(), StackAction()].map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
),
const ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: CleanupLocalAction()),
),
if (ownsAlbum)
ActionColumnButtonWidget(
action: TimelineAction(
action: SetAlbumCoverAction(assets: assets, albumId: widget.album.id, scope: scope),
),
source: .timeline,
action: TimelineAction(action: RemoveFromAlbumAction(albumId: widget.album.id)),
),
if (ownsAlbum && multiselect.selectedAssets.length == 1)
ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: SetAlbumCoverAction(albumId: widget.album.id)),
),
],
slivers: ownsAlbum

View File

@@ -1,21 +1,16 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TrashBottomBar extends ConsumerWidget {
const TrashBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
final scope = ActionScope.from(context, ref);
return Align(
alignment: Alignment.bottomCenter,
child: Container(
@@ -25,10 +20,14 @@ class TrashBottomBar extends ConsumerWidget {
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteAction(assets: assets, scope: scope),
RestoreAction(assets: assets, scope: scope),
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))).toList(growable: false),
children: const [DeleteAction(), RestoreAction()]
.map(
(action) => ActionColumnButtonWidget(
source: .timeline,
action: TimelineAction(action: action),
),
)
.toList(growable: false),
),
),
),

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
@@ -11,13 +10,10 @@ import 'package:immich_mobile/models/download/livephotos_medatada.model.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';
import 'package:immich_mobile/providers/infrastructure/tag.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/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>(ActionNotifier.new, dependencies: [multiSelectProvider]);
@@ -75,29 +71,6 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
}
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) {
localIds.add(asset.localId!);
}
}
return localIds;
}
List<String> _getOwnedRemoteIdsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id;
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
}
Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
@@ -108,17 +81,6 @@ class ActionNotifier extends Notifier<void> {
};
}
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
final ids = _getRemoteIdsForSource(source);
try {
await _service.shareLink(ids, context);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to create shared link for assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> emptyTrash(String userId) async {
try {
final count = await _service.emptyTrash(userId);
@@ -139,77 +101,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> trashRemoteAndDeleteLocal(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source);
try {
await _service.trashRemoteAndDeleteLocal(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to delete assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> deleteRemoteAndLocal(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source);
try {
await _service.deleteRemoteAndLocal(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to delete assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
final assets = _getAssets(source);
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
? true
: await showDialog<bool>(
context: context,
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
);
if (backedUpOnly == null) {
// User cancelled the dialog
return null;
}
final List<String> ids;
if (backedUpOnly) {
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
} else {
ids = _getLocalIdsForSource(source);
}
try {
final deletedCount = await _service.deleteLocal(ids);
return ActionResult(count: deletedCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to delete assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult?> tagAssets(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final count = await _service.tagAssets(ids, context);
if (count == null) {
return null;
}
ref.invalidate(tagProvider);
return ActionResult(count: count, success: true);
} catch (error, stack) {
_logger.severe('Failed to tag assets', error, stack);
ref.invalidate(tagProvider);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> addToAlbum(ActionSource source, RemoteAlbum album) async {
final selected = _getAssets(source).toList(growable: false);
if (selected.isEmpty) {
@@ -288,42 +179,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
final ids = _getAssets(source).toList(growable: false);
try {
final count = await _service.shareAssets(
ids,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
return ActionResult(count: count, success: count > 0 || ids.isEmpty);
} catch (error, stack) {
_logger.severe('Failed to share assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> downloadAll(ActionSource source) async {
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
try {
final didEnqueue = await _service.downloadAll(assets);
final enqueueCount = didEnqueue.where((e) => e).length;
return ActionResult(count: enqueueCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to download assets', error, stack);
return ActionResult(count: assets.length, success: false, error: error.toString());
}
}
Future<ActionResult> upload(
ActionSource source, {
List<LocalAsset>? assets,
@@ -408,11 +263,4 @@ class ActionNotifier extends Notifier<void> {
extension on Iterable<RemoteAsset> {
Iterable<String> toIds() => map((e) => e.id);
Iterable<RemoteAsset> ownedAssets(String? ownerId) {
if (ownerId == null) {
return const [];
}
return whereType<RemoteAsset>().where((a) => a.ownerId == ownerId);
}
}

View File

@@ -1,82 +1,19 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/tag_picker.dart';
final actionServiceProvider = Provider<ActionService>(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(tagServiceProvider),
),
(ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider)),
);
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final TagService _tagService;
const ActionService(
this._assetApiRepository,
this._remoteAssetRepository,
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
this._tagService,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
const ActionService(this._assetApiRepository, this._remoteAssetRepository);
Future<int> emptyTrash(String userId) async {
final count = await _assetApiRepository.emptyTrash();
@@ -90,28 +27,6 @@ class ActionService {
return count;
}
Future<void> trashRemoteAndDeleteLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, false);
await _remoteAssetRepository.trash(remoteIds);
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<void> deleteRemoteAndLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, true);
await _remoteAssetRepository.delete(remoteIds);
if (localIds.isNotEmpty) {
await _deleteLocalAssets(localIds);
}
}
Future<int> deleteLocal(List<String> localIds) async {
return await _deleteLocalAssets(localIds);
}
Future<bool> updateDescription(String assetId, String description) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateDescription(assetId, description);
@@ -127,74 +42,4 @@ class ActionService {
return true;
}
Future<int?> tagAssets(List<String> remoteIds, BuildContext context) async {
final tagResults = await showTagPickerModal(context: context);
if (tagResults == null) {
// user cancelled
return null;
}
final selectedTagIds = Set<String>.from(tagResults.$1);
final selectedNewTagValues = tagResults.$2;
if (selectedNewTagValues.isNotEmpty) {
final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList());
selectedTagIds.addAll(upsertedTags.map((t) => t.id));
}
if (selectedTagIds.isEmpty) {
return 0;
}
return _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList());
}
Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);
}
Future<void> unStack(List<String> stackIds) async {
await _remoteAssetRepository.unStack(stackIds);
await _assetApiRepository.unStack(stackIds);
}
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetType fileType = ShareAssetType.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
fileType: fileType,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
}
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
Future<bool> setAlbumCover(String albumId, String assetId) async {
final owner = await _remoteAlbumRepository.getOwner(albumId);
final updatedAlbum = await _albumApiRepository.updateAlbum(albumId, owner, thumbnailAssetId: assetId);
await _remoteAlbumRepository.update(updatedAlbum);
return true;
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
return deletedIds.length;
}
}

View File

@@ -1,34 +1,32 @@
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/album/album.model.dart';
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.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/cast.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/actions/share_link.action.dart';
import 'package:immich_mobile/presentation/actions/similar_photos.action.dart';
import 'package:immich_mobile/presentation/actions/slideshow.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_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/like_activity_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';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
@@ -169,62 +167,48 @@ enum ActionButtonType {
Widget buildButton(
ActionButtonContext context,
BuildContext buildContext,
WidgetRef ref, [
BuildContext buildContext, [
bool iconOnly = false,
bool menuItem = false,
]) {
final scope = ActionScope.from(buildContext, ref);
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(
action: AssetDebugAction(assets: [context.asset], scope: scope),
),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
ActionButtonType.advancedInfo => ActionMenuItemWidget(source: context.source, action: const AssetDebugAction()),
ActionButtonType.share => ActionMenuItemWidget(source: context.source, action: const ShareAction()),
ActionButtonType.shareLink => ActionMenuItemWidget(source: context.source, action: const ShareLinkAction()),
ActionButtonType.slideshow => ActionMenuItemWidget(source: context.source, action: const SlideshowAction()),
ActionButtonType.archive ||
ActionButtonType.archive ||
ActionButtonType.archive => ActionMenuItemWidget(source: context.source, action: const ArchiveAction()),
ActionButtonType.restoreTrash => ActionMenuItemWidget(source: context.source, action: const RestoreAction()),
ActionButtonType.delete => ActionMenuItemWidget(source: context.source, action: const DeleteAction()),
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(source: context.source, action: const LockAction()),
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(source: context.source, action: const LockAction()),
ActionButtonType.deleteLocal => ActionMenuItemWidget(source: context.source, action: const CleanupLocalAction()),
ActionButtonType.upload => ActionMenuItemWidget(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
action: UploadAction(source: context.source),
),
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
action: ArchiveAction(assets: [context.asset], scope: scope),
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => ActionMenuItemWidget(
action: RestoreAction(assets: [context.asset], scope: scope),
),
ActionButtonType.delete => ActionMenuItemWidget(
action: DeleteAction(assets: [context.asset], scope: scope),
),
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
action: LockAction(assets: [context.asset], scope: scope),
),
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
action: LockAction(assets: [context.asset], scope: scope),
),
ActionButtonType.deleteLocal => ActionMenuItemWidget(
action: CleanupLocalAction(assets: [context.asset], scope: scope),
),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.removeFromAlbum => ActionMenuItemWidget(
action: RemoveFromAlbumAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
source: context.source,
action: RemoveFromAlbumAction(albumId: context.currentAlbum!.id),
),
ActionButtonType.setAlbumCover => ActionMenuItemWidget(
action: SetAlbumCoverAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
source: context.source,
action: SetAlbumCoverAction(albumId: context.currentAlbum!.id),
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => ActionMenuItemWidget(
action: StackAction(assets: [context.asset], scope: scope),
),
ActionButtonType.unstack => ActionMenuItemWidget(source: context.source, action: const StackAction()),
ActionButtonType.openInBrowser => ActionMenuItemWidget(
action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin, scope: scope),
source: context.source,
action: OpenInBrowserAction(origin: context.timelineOrigin),
),
ActionButtonType.similarPhotos => ActionMenuItemWidget(
action: SimilarPhotosAction(assetId: (context.asset as RemoteAsset).id, scope: scope),
source: context.source,
action: const SimilarPhotosAction(),
),
ActionButtonType.setProfilePicture => ActionMenuItemWidget(
action: SetProfilePictureAction(asset: context.asset, scope: scope),
source: context.source,
action: const SetProfilePictureAction(),
),
ActionButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
@@ -242,7 +226,9 @@ enum ActionButtonType {
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ActionButtonType.cast => ActionMenuItemWidget(action: CastAction(scope: scope)),
ActionButtonType.cast => ActionMenuItemWidget(source: context.source, action: const CastAction()),
ActionButtonType.download => ActionMenuItemWidget(source: context.source, action: const DownloadAction()),
ActionButtonType.unarchive => ActionMenuItemWidget(source: context.source, action: const ArchiveAction()),
};
}
@@ -282,14 +268,14 @@ class ActionButtonBuilder {
ActionButtonType.restoreTrash,
};
static List<Widget> build(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
static List<Widget> build(ActionButtonContext context, BuildContext buildContext) {
return _actionTypes
.where((type) => type.shouldShow(context))
.map((type) => type.buildButton(context, buildContext, ref))
.map((type) => type.buildButton(context, buildContext))
.toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
@@ -305,7 +291,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, ref, false, true));
result.add(type.buildButton(context, buildContext, false, true));
lastGroup = type.kebabMenuGroup;
}

View File

@@ -20,5 +20,5 @@ extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implem
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
AssetFilter<BaseAsset> backedUp() => where((asset) => asset.isMerged);
AssetFilter<BaseAsset> backedUp({bool isBackedUp = true}) => where((asset) => asset.isMerged == isBackedUp);
}

View File

@@ -8,6 +8,7 @@ class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final bool disabled;
final bool? loading;
@@ -16,6 +17,7 @@ class ImmichColumnButton extends StatefulWidget {
required this.icon,
required this.label,
required this.onPressed,
this.onLongPress,
this.disabled = false,
this.loading,
});
@@ -28,10 +30,10 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -42,9 +44,12 @@ class _ImmichColumnButtonState extends State<ImmichColumnButton> {
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
final handlerDisabled = widget.disabled || _isLoading;
final onLongPress = widget.onLongPress;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),

View File

@@ -7,6 +7,7 @@ import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
@@ -16,6 +17,7 @@ class ImmichIconButton extends StatefulWidget {
super.key,
required this.icon,
required this.onPressed,
this.onLongPress,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
@@ -30,10 +32,10 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -66,6 +68,9 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
},
};
final handlerDisabled = widget.disabled || _isLoading;
final onLongPress = widget.onLongPress;
return IconButton(
icon: _isLoading
? const SizedBox.square(
@@ -73,7 +78,8 @@ class _ImmichIconButtonState extends State<ImmichIconButton> {
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
onPressed: handlerDisabled ? null : () => _run(widget.onPressed),
onLongPress: handlerDisabled || onLongPress == null ? null : () => _run(onLongPress),
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}

View File

@@ -7,6 +7,7 @@ class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final FutureOr<void> Function()? onLongPress;
final ImmichVariant variant;
final bool expanded;
final bool disabled;
@@ -17,6 +18,7 @@ class ImmichTextButton extends StatefulWidget {
required this.labelText,
this.icon,
required this.onPressed,
this.onLongPress,
this.variant = .filled,
this.expanded = true,
@@ -32,10 +34,10 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
Future<void> _run(FutureOr<void> Function() action) async {
setState(() => _loading = true);
try {
await widget.onPressed();
await action();
} finally {
if (mounted) {
setState(() => _loading = false);
@@ -59,11 +61,26 @@ class _ImmichTextButtonState extends State<ImmichTextButton> {
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final handlerDisabled = widget.disabled || _isLoading;
final longPress = widget.onLongPress;
final onPressed = handlerDisabled ? null : () => _run(widget.onPressed);
final onLongPress = handlerDisabled || longPress == null ? null : () => _run(longPress);
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.filled => ElevatedButton.icon(
style: style,
onPressed: onPressed,
onLongPress: onLongPress,
icon: icon,
label: label,
),
ImmichVariant.ghost => TextButton.icon(
style: style,
onPressed: onPressed,
onLongPress: onLongPress,
icon: icon,
label: label,
),
};
if (widget.expanded) {

View File

@@ -3,6 +3,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:mocktail/mocktail.dart';
@@ -10,6 +11,8 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {}
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
class MockDownloadRepository extends Mock implements DownloadRepository {}
class MockPermissionRepository extends Mock implements IPermissionRepository {}
class MockAuthApiRepository extends Mock implements AuthApiRepository {}

View File

@@ -2,32 +2,21 @@ 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/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class MockDownloadRepository extends Mock implements DownloadRepository {}
void main() {
late ActionService sut;
late MockAssetApiRepository assetApiRepository;
late MockRemoteAssetRepository remoteAssetRepository;
late MockDriftLocalAssetRepository localAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late MockTagService tagService;
late Drift db;
@@ -48,25 +37,8 @@ void main() {
setUp(() {
assetApiRepository = MockAssetApiRepository();
remoteAssetRepository = MockRemoteAssetRepository();
localAssetRepository = MockDriftLocalAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
tagService = MockTagService();
sut = ActionService(
assetApiRepository,
remoteAssetRepository,
localAssetRepository,
albumApiRepository,
remoteAlbumRepository,
trashedLocalAssetRepository,
assetMediaRepository,
downloadRepository,
tagService,
);
sut = ActionService(assetApiRepository, remoteAssetRepository);
});
tearDown(() async {
@@ -98,50 +70,4 @@ void main() {
verify(() => remoteAssetRepository.updateRating(assetId, null)).called(1);
});
});
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b'];
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)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c'];
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)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('short-circuits when nothing was deleted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
final result = await sut.deleteLocal(ids);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
});
}

View File

@@ -1,13 +1,16 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:mocktail/mocktail.dart' as mock;
@@ -33,6 +36,8 @@ class RepositoryMocks {
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
final assetApi = AssetApiRepositoryStub(MockAssetApiRepository());
final assetMedia = AssetMediaRepositoryStub(MockAssetMediaRepository());
final download = DownloadRepositoryStub(MockDownloadRepository());
RepositoryMocks() {
resetAll();
@@ -49,6 +54,8 @@ class RepositoryMocks {
reset(albumApi);
nativeApi.reset();
assetApi.reset();
assetMedia.reset();
download.reset();
reset(toast);
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
@@ -56,6 +63,8 @@ class RepositoryMocks {
_stubRemoteExifRepository();
_stubNativeSyncApi();
_stubAssetApiRepository();
_stubAssetMediaRepository();
_stubDownloadRepository();
}
void _stubRemoteAssetRepository() {
@@ -85,6 +94,14 @@ class RepositoryMocks {
void _stubAssetApiRepository() {
when(assetApi.update).thenAnswer((_) async => {});
}
void _stubAssetMediaRepository() {
when(assetMedia.shareAssets).thenAnswer((_) async => 1);
}
void _stubDownloadRepository() {
when(download.downloadAllAssets).thenAnswer((_) async => const []);
}
}
class ServiceMocks {
@@ -93,6 +110,9 @@ class ServiceMocks {
final asset = AssetServiceStub(MockAssetService());
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
final cleanup = CleanupServiceStub(MockCleanupService());
final tag = TagServiceStub(MockTagService());
final backgroundSync = MockBackgroundSyncManager();
final upload = MockForegroundUploadService();
ServiceMocks() {
resetAll();
@@ -105,11 +125,17 @@ class ServiceMocks {
asset.reset();
album.reset();
cleanup.reset();
tag.reset();
reset(backgroundSync);
reset(upload);
_stubUserService();
_stubPartnerService();
_stubAssetService();
_stubRemoteAlbumService();
_stubCleanupService();
_stubTagService();
_stubBackgroundSync();
_stubForegroundUpload();
}
void _stubUserService() {
@@ -147,6 +173,23 @@ class ServiceMocks {
void _stubCleanupService() {
when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0);
}
void _stubTagService() {
when(tag.bulkTagAssets).thenAnswer((_) async => 0);
when(tag.upsertTags).thenAnswer((_) async => const []);
when(tag.getAllTags).thenAnswer((_) async => const {});
}
void _stubBackgroundSync() {
when(() => backgroundSync.syncLocal()).thenAnswer((_) async {});
when(() => backgroundSync.hashAssets()).thenAnswer((_) async {});
}
void _stubForegroundUpload() {
when(
() => upload.uploadManual(any(), cancelToken: any(named: 'cancelToken'), callbacks: any(named: 'callbacks')),
).thenAnswer((_) async {});
}
}
void _registerFallbacks() {
@@ -161,8 +204,16 @@ void _registerFallbacks() {
registerFallbackValue(const Option<LatLng>.none());
registerFallbackValue(const Option<String>.none());
registerFallbackValue(const Option<DateTime>.none());
registerFallbackValue(<BaseAsset>[]);
registerFallbackValue(<RemoteAsset>[]);
registerFallbackValue(<LocalAsset>[]);
registerFallbackValue(ShareAssetType.original);
registerFallbackValue(const UploadCallbacks());
registerFallbackValue(_FakeBuildContext());
}
class _FakeBuildContext extends Fake implements BuildContext {}
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
}
@@ -316,3 +367,30 @@ extension type const AssetApiRepositoryStub(MockAssetApiRepository api) implemen
location: any(named: 'location'),
);
}
extension type const AssetMediaRepositoryStub(MockAssetMediaRepository api) implements Stub<MockAssetMediaRepository> {
Future<int> Function() get shareAssets =>
() => api.shareAssets(
any(),
any(),
fileType: any(named: 'fileType'),
cancelCompleter: any(named: 'cancelCompleter'),
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
);
}
extension type const DownloadRepositoryStub(MockDownloadRepository repo) implements Stub<MockDownloadRepository> {
Future<List<bool>> Function() get downloadAllAssets =>
() => repo.downloadAllAssets(any());
}
extension type const TagServiceStub(MockTagService service) implements Stub<MockTagService> {
Future<int> Function() get bulkTagAssets =>
() => service.bulkTagAssets(any(), any());
Future<List<Tag>> Function() get upsertTags =>
() => service.upsertTags(any());
Future<Set<Tag>> Function() get getAllTags =>
() => service.getAllTags();
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_ui/immich_ui.dart';
import '../presentation_context.dart';
class _RecordingAction extends BaseAction {
final void Function() onTap;
final void Function()? onLong;
final bool visible;
const _RecordingAction({required this.onTap, this.onLong, this.visible = true});
@override
WidgetAction? resolve(ActionScope scope) {
if (!visible) {
return null;
}
final onLong = this.onLong;
return WidgetAction(
icon: Icons.bug_report_outlined,
label: 'test',
onAction: () async => onTap(),
onSecondaryAction: onLong == null ? null : () async => onLong(),
);
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
group('ActionIconButtonWidget', () {
testWidgets('renders nothing when the action is not visible', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: _RecordingAction(onTap: () {}, visible: false)),
);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('wires no long press handler when the action has no secondary action', (tester) async {
await tester.pumpTestWidget(context, ActionIconButtonWidget(action: _RecordingAction(onTap: () {})));
expect(tester.widget<ImmichIconButton>(find.byType(ImmichIconButton)).onLongPress, isNull);
});
testWidgets('tap runs the primary action', (tester) async {
var taps = 0;
var longPresses = 0;
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: _RecordingAction(onTap: () => taps++, onLong: () => longPresses++),
),
);
await tester.tap(find.byType(ImmichIconButton));
await tester.pump();
expect(taps, 1);
expect(longPresses, 0);
});
testWidgets('long press runs the secondary action, not the primary', (tester) async {
var taps = 0;
var longPresses = 0;
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: _RecordingAction(onTap: () => taps++, onLong: () => longPresses++),
),
);
await tester.longPress(find.byType(ImmichIconButton));
await tester.pump();
expect(longPresses, 1);
expect(taps, 0);
});
});
}

View File

@@ -26,36 +26,33 @@ void main() {
RemoteAsset owned({AssetVisibility visibility = AssetVisibility.timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
const action = ArchiveAction();
group('ArchiveAction', () {
testWidgets('archives the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
expect(action.icon, Icons.archive_outlined);
expect(action.label, StaticTranslations.instance.archive);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.archive_outlined);
expect(resolved.label, StaticTranslations.instance.archive);
verify(() => assetService.update([asset.id], visibility: const Some(AssetVisibility.archive))).called(1);
});
testWidgets('unarchive the eligible owned assets', (tester) async {
final asset = owned(visibility: .archive);
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
expect(action.icon, Icons.unarchive_outlined);
expect(action.label, StaticTranslations.instance.unarchive);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.unarchive_outlined);
expect(resolved.label, StaticTranslations.instance.unarchive);
verify(() => assetService.update([asset.id], visibility: const Some(AssetVisibility.timeline))).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .archive);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(
context,
(scope) => ArchiveAction(assets: [mine, theirs], scope: scope),
);
expect(action.label, StaticTranslations.instance.unarchive);
final resolved = await tester.runAction(context, action, assets: [mine, theirs]);
expect(resolved!.label, StaticTranslations.instance.unarchive);
verify(() => assetService.update([mine.id], visibility: const Some(AssetVisibility.timeline))).called(1);
});
@@ -63,7 +60,7 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [first, second], scope: scope));
await tester.runAction(context, action, assets: [first, second]);
verify(
() => assetService.update([first.id, second.id], visibility: const Some(AssetVisibility.archive)),
@@ -74,7 +71,7 @@ void main() {
final stale = owned();
final alreadyArchived = owned(visibility: .archive);
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [stale, alreadyArchived], scope: scope));
await tester.runAction(context, action, assets: [stale, alreadyArchived]);
verify(() => assetService.update([stale.id], visibility: const Some(AssetVisibility.archive))).called(1);
});
@@ -82,7 +79,7 @@ void main() {
testWidgets('reports the archived count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [owned(), owned()], scope: scope));
await tester.runAction(context, action, assets: [owned(), owned()]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.archive_action_prompt(count: 2));
@@ -91,15 +88,13 @@ void main() {
testWidgets('reports the unarchive count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
await tester.runAction(
context,
(scope) => ArchiveAction(
assets: [
owned(visibility: .archive),
owned(visibility: .archive),
],
scope: scope,
),
action,
assets: [
owned(visibility: .archive),
owned(visibility: .archive),
],
);
final message = verify(() => toast.success(captureAny())).captured.single as String;

View File

@@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.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/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
@@ -19,33 +18,30 @@ void main() {
context.dispose();
});
const action = AssetDebugAction();
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpActionButton(
context,
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(find.byType(ImmichIconButton), findsOneWidget);
expect(resolved, isNotNull);
});
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpActionButton(
final resolved = await tester.resolveAction(
context,
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()], scope: scope),
action,
assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()],
);
expect(find.byType(ImmichIconButton), findsNothing);
expect(resolved, isNull);
});
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpActionButton(
context,
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(find.byType(ImmichIconButton), findsNothing);
expect(resolved, isNull);
});
});
}

View File

@@ -53,13 +53,14 @@ void main() {
await tester.pumpAndSettle();
}
const action = DeleteAction();
group('DeleteAction', () {
group('trash', () {
testWidgets('trashes a remote-only owned asset', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
await tester.runAction(context, action, assets: [asset]);
verify(() => assetService.trash([asset.id])).called(1);
verifyNever(() => assetService.delete(any()));
@@ -70,8 +71,7 @@ void main() {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [mine, theirs], scope: scope));
await tester.pumpAndSettle();
await tester.runAction(context, action, assets: [mine, theirs]);
verify(() => assetService.trash([mine.id])).called(1);
});
@@ -79,8 +79,7 @@ void main() {
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
final asset = owned(localId: 'local');
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
await tester.runAction(context, action, assets: [asset]);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
verify(() => assetService.trash([asset.id])).called(1);
@@ -91,11 +90,7 @@ void main() {
testWidgets('permanently deletes when the trash feature is disabled', (tester) async {
final asset = owned();
await tester.pumpTestAction(
context,
(scope) => DeleteAction(assets: [asset], scope: scope),
overrides: disableTrash(),
);
await tester.runAction(context, action, assets: [asset], overrides: disableTrash());
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
@@ -105,11 +100,7 @@ void main() {
testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async {
final asset = owned(localId: 'local');
await tester.pumpTestAction(
context,
(scope) => DeleteAction(assets: [asset], scope: scope),
overrides: disableTrash(),
);
await tester.runAction(context, action, assets: [asset], overrides: disableTrash());
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
@@ -119,7 +110,7 @@ void main() {
testWidgets('permanently deletes already trashed assets even with trash enabled', (tester) async {
final asset = owned(deletedAt: DateTime(2024));
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.runAction(context, action, assets: [asset]);
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
@@ -129,7 +120,7 @@ void main() {
testWidgets('permanently deletes locked folder assets even with trash enabled', (tester) async {
final asset = owned(visibility: .locked, localId: 'local');
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.runAction(context, action, assets: [asset]);
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
@@ -139,7 +130,7 @@ void main() {
testWidgets('does nothing when the confirmation is cancelled', (tester) async {
final asset = owned(visibility: .locked, localId: 'local');
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.runAction(context, action, assets: [asset]);
await respondToDialog(tester, confirm: false);
verifyNever(() => assetService.delete(any()));
@@ -151,8 +142,7 @@ void main() {
testWidgets('removes the device copy with no remote call', (tester) async {
final asset = LocalAssetFactory.create();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
await tester.runAction(context, action, assets: [asset]);
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
@@ -164,11 +154,7 @@ void main() {
testWidgets('permanent delete shows a single app dialog', (tester) async {
final asset = owned(localId: 'local');
await tester.pumpTestAction(
context,
(scope) => DeleteAction(assets: [asset], scope: scope),
overrides: disableTrash(),
);
await tester.runAction(context, action, assets: [asset], overrides: disableTrash());
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.delete_dialog_title), findsOneWidget);
@@ -185,7 +171,7 @@ void main() {
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
final asset = LocalAssetFactory.create();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.runAction(context, action, assets: [asset]);
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsOneWidget);
@@ -199,23 +185,21 @@ void main() {
});
group('CleanupLocalAction', () {
const cleanup = CleanupLocalAction();
testWidgets('deletes only backed up device copies', (tester) async {
final backedUp = LocalAssetFactory.create(remoteId: 'remote');
final localOnly = LocalAssetFactory.create();
await tester.pumpTestAction(context, (scope) => CleanupLocalAction(assets: [backedUp, localOnly], scope: scope));
await tester.pumpAndSettle();
await tester.runAction(context, cleanup, assets: [backedUp, localOnly]);
verify(() => cleanupService.deleteLocalAssets([backedUp.id])).called(1);
});
testWidgets('is hidden when no backed up assets are selected', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => CleanupLocalAction(assets: [LocalAssetFactory.create()], scope: scope),
);
final resolved = await tester.resolveAction(context, cleanup, assets: [LocalAssetFactory.create()]);
expect(action.isVisible, isFalse);
expect(resolved, isNull);
});
});
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../repository.mocks.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockDownloadRepository downloadRepo;
setUp(() async {
context = await PresentationContext.create();
downloadRepo = context.repository.download.repo;
});
tearDown(() {
context.dispose();
});
const action = DownloadAction();
group('DownloadAction', () {
testWidgets('visible when there is a remote asset to download', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(resolved, isNotNull);
expect(resolved!.icon, Icons.download);
expect(resolved.label, StaticTranslations.instance.download);
});
testWidgets('hidden when the selection has no remote assets', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: const []);
expect(resolved, isNull);
});
testWidgets('enqueues the downloads and re-syncs shortly after', (tester) async {
final target = RemoteAssetFactory.create();
final backgroundSync = context.service.backgroundSync;
await tester.runAction(context, action, assets: [target]);
verify(() => downloadRepo.downloadAllAssets(any(that: contains(target)))).called(1);
// Sync is scheduled to run after a 1 second delay
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
verify(() => backgroundSync.syncLocal()).called(1);
verify(() => backgroundSync.hashAssets()).called(1);
});
});
}

View File

@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:mocktail/mocktail.dart';
@@ -44,66 +43,51 @@ void main() {
RemoteAsset owned({AssetType type = .image}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, type: type);
group('EditImageAction', () {
testWidgets('visible for a single owned editable asset on a supported server', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [owned()], scope: scope),
);
group('EditAssetAction', () {
const action = EditAssetAction();
expect(action.isVisible, isTrue);
expect(action.icon, Icons.tune);
expect(action.label, StaticTranslations.instance.edit);
expect(find.byType(ImmichIconButton), findsOneWidget);
testWidgets('visible for a single owned editable asset on a supported server', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [owned()]);
expect(resolved, isNotNull);
expect(resolved!.icon, Icons.tune);
expect(resolved.label, StaticTranslations.instance.edit);
});
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
final action = await tester.pumpActionButton(
final resolved = await tester.resolveAction(
context,
(scope) => EditAssetAction(assets: [owned()], scope: scope),
action,
assets: [owned()],
overrides: serverVersion(unsupportedVersion),
);
expect(action.isVisible, isFalse);
expect(find.byType(ImmichIconButton), findsNothing);
expect(resolved, isNull);
});
testWidgets('hidden for more than one asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [owned(), owned()], scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [owned(), owned()]);
expect(action.isVisible, isFalse);
expect(resolved, isNull);
});
testWidgets('hidden for an asset owned by someone else', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(action.isVisible, isFalse);
expect(resolved, isNull);
});
testWidgets('hidden for a non-editable asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditAssetAction(
assets: [owned(type: AssetType.video)],
scope: scope,
),
);
final resolved = await tester.resolveAction(context, action, assets: [owned(type: AssetType.video)]);
expect(action.isVisible, isFalse);
expect(resolved, isNull);
});
testWidgets('reads the edits and exif for the asset from the repository', (tester) async {
final asset = owned();
final remoteAssetRepo = context.repository.remoteAsset.repo;
await tester.pumpTestAction(context, (scope) => EditAssetAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
await tester.runAction(context, action, assets: [asset]);
verify(() => remoteAssetRepo.getAssetEdits(asset.id)).called(1);
verify(() => remoteAssetRepo.getExif(asset.id)).called(1);
@@ -135,36 +119,20 @@ void main() {
});
group('EditLocationAction', () {
testWidgets('visible with an owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditLocationAction(assets: [owned()], scope: scope),
);
const action = EditLocationAction();
expect(action.isVisible, isTrue);
expect(action.icon, Icons.edit_location_alt_outlined);
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_edit_location);
testWidgets('visible with an owned remote asset', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [owned()]);
expect(resolved, isNotNull);
expect(resolved!.icon, Icons.edit_location_alt_outlined);
expect(resolved.label, StaticTranslations.instance.control_bottom_app_bar_edit_location);
});
testWidgets('hidden without any owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditLocationAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(action.isVisible, isFalse);
});
testWidgets('collects only the owned remote asset ids', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpActionButton(
context,
(scope) => EditLocationAction(assets: [mine, theirs], scope: scope),
);
expect((action as EditLocationAction).assetIds, [mine.id]);
expect(resolved, isNull);
});
testWidgets('save persists the location, refreshes the viewer exif and toasts', (tester) async {
@@ -172,15 +140,12 @@ void main() {
final toast = context.repository.toast;
when(() => assetService.getExif(asset)).thenAnswer((_) async => null);
late EditLocationAction action;
late WidgetRef capturedRef;
await tester.pumpTestWidget(
context,
Consumer(
builder: (ctx, ref, _) {
action = EditLocationAction(
assets: [asset],
scope: ActionScope(context: ctx, ref: ref, authUser: context.currentUser),
);
builder: (_, ref, _) {
capturedRef = ref;
// Keep the exif provider alive so a re-fetch after invalidation is observable.
ref.watch(assetExifProvider(asset));
return const SizedBox.shrink();
@@ -189,7 +154,8 @@ void main() {
);
await tester.pumpAndSettle();
await action.save(const LatLng(1, 2));
final scope = ActionScope(assets: [asset], authUser: context.currentUser, ref: capturedRef);
await action.save(scope, [asset.id], const LatLng(1, 2));
await tester.pumpAndSettle();
final location =
@@ -206,36 +172,20 @@ void main() {
});
group('EditDateTimeAction', () {
testWidgets('visible with an owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditDateTimeAction(assets: [owned()], scope: scope),
);
const action = EditDateTimeAction();
expect(action.isVisible, isTrue);
expect(action.icon, Icons.edit_calendar_outlined);
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_edit_time);
testWidgets('visible with an owned remote asset', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [owned()]);
expect(resolved, isNotNull);
expect(resolved!.icon, Icons.edit_calendar_outlined);
expect(resolved.label, StaticTranslations.instance.control_bottom_app_bar_edit_time);
});
testWidgets('hidden without any owned remote asset', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => EditDateTimeAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(action.isVisible, isFalse);
});
testWidgets('collects only the owned remote asset ids', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpActionButton(
context,
(scope) => EditDateTimeAction(assets: [mine, theirs], scope: scope),
);
expect((action as EditDateTimeAction).assetIds, [mine.id]);
expect(resolved, isNull);
});
testWidgets('save persists the date, refreshes the viewer exif and toasts', (tester) async {
@@ -244,16 +194,12 @@ void main() {
const picked = '2026-06-10T19:15:00.000+06:00';
when(() => assetService.getExif(asset)).thenAnswer((_) async => null);
late EditDateTimeAction action;
late WidgetRef capturedRef;
await tester.pumpTestWidget(
context,
Consumer(
builder: (ctx, ref, _) {
action = EditDateTimeAction(
assets: [asset],
scope: ActionScope(context: ctx, ref: ref, authUser: context.currentUser),
);
// Keep the exif provider alive so a re-fetch after invalidation is observable.
builder: (_, ref, _) {
capturedRef = ref;
ref.watch(assetExifProvider(asset));
return const SizedBox.shrink();
},
@@ -261,7 +207,8 @@ void main() {
);
await tester.pumpAndSettle();
await action.save(picked);
final scope = ActionScope(assets: [asset], authUser: context.currentUser, ref: capturedRef);
await action.save(scope, [asset.id], picked);
await tester.pumpAndSettle();
verify(() => assetService.update([asset.id], dateTime: const Some(picked))).called(1);

View File

@@ -26,36 +26,33 @@ void main() {
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
const action = FavoriteAction();
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
expect(action.icon, Icons.favorite_border_rounded);
expect(action.label, StaticTranslations.instance.favorite);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.favorite_border_rounded);
expect(resolved.label, StaticTranslations.instance.favorite);
verify(() => assetService.update([asset.id], isFavorite: const Some(true))).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
expect(action.icon, Icons.favorite_rounded);
expect(action.label, StaticTranslations.instance.unfavorite);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.favorite_rounded);
expect(resolved.label, StaticTranslations.instance.unfavorite);
verify(() => assetService.update([asset.id], isFavorite: const Some(false))).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(isFavorite: true);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(
context,
(scope) => FavoriteAction(assets: [mine, theirs], scope: scope),
);
expect(action.label, StaticTranslations.instance.unfavorite);
final resolved = await tester.runAction(context, action, assets: [mine, theirs]);
expect(resolved!.label, StaticTranslations.instance.unfavorite);
verify(() => assetService.update([mine.id], isFavorite: const Some(false))).called(1);
});
@@ -63,7 +60,7 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [first, second], scope: scope));
await tester.runAction(context, action, assets: [first, second]);
verify(() => assetService.update([first.id, second.id], isFavorite: const Some(true))).called(1);
});
@@ -72,7 +69,7 @@ void main() {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [stale, alreadyFavorite], scope: scope));
await tester.runAction(context, action, assets: [stale, alreadyFavorite]);
verify(() => assetService.update([stale.id], isFavorite: const Some(true))).called(1);
});
@@ -80,7 +77,7 @@ void main() {
testWidgets('reports the favorite count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [owned(), owned()], scope: scope));
await tester.runAction(context, action, assets: [owned(), owned()]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.favorite_action_prompt(count: 2));
@@ -89,10 +86,7 @@ void main() {
testWidgets('reports the unfavorite count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
context,
(scope) => FavoriteAction(assets: [owned(isFavorite: true), owned(isFavorite: true)], scope: scope),
);
await tester.runAction(context, action, assets: [owned(isFavorite: true), owned(isFavorite: true)]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unfavorite_action_prompt(count: 2));

View File

@@ -26,33 +26,33 @@ void main() {
RemoteAsset owned({AssetVisibility visibility = .timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
const action = LockAction();
group('LockAction', () {
testWidgets('locks the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
expect(action.icon, Icons.lock_rounded);
expect(action.label, StaticTranslations.instance.move_to_locked_folder);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.lock_rounded);
expect(resolved.label, StaticTranslations.instance.move_to_locked_folder);
verify(() => assetService.update([asset.id], visibility: const Some(.locked))).called(1);
});
testWidgets('unlocks the eligible owned assets', (tester) async {
final asset = owned(visibility: .locked);
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
expect(action.icon, Icons.lock_open_rounded);
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.lock_open_rounded);
expect(resolved.label, StaticTranslations.instance.remove_from_locked_folder);
verify(() => assetService.update([asset.id], visibility: const Some(.timeline))).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .locked);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [mine, theirs], scope: scope));
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
final resolved = await tester.runAction(context, action, assets: [mine, theirs]);
expect(resolved!.label, StaticTranslations.instance.remove_from_locked_folder);
verify(() => assetService.update([mine.id], visibility: const Some(.timeline))).called(1);
});
@@ -60,7 +60,7 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => LockAction(assets: [first, second], scope: scope));
await tester.runAction(context, action, assets: [first, second]);
verify(() => assetService.update([first.id, second.id], visibility: const Some(.locked))).called(1);
});
@@ -69,7 +69,7 @@ void main() {
final stale = owned();
final alreadyLocked = owned(visibility: .locked);
await tester.pumpTestAction(context, (scope) => LockAction(assets: [stale, alreadyLocked], scope: scope));
await tester.runAction(context, action, assets: [stale, alreadyLocked]);
verify(() => assetService.update([stale.id], visibility: const Some(.locked))).called(1);
});
@@ -77,7 +77,7 @@ void main() {
testWidgets('reports the locked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => LockAction(assets: [owned(), owned()], scope: scope));
await tester.runAction(context, action, assets: [owned(), owned()]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.move_to_lock_folder_action_prompt(count: 2));
@@ -86,15 +86,13 @@ void main() {
testWidgets('reports the unlocked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
await tester.runAction(
context,
(scope) => LockAction(
assets: [
owned(visibility: .locked),
owned(visibility: .locked),
],
scope: scope,
),
action,
assets: [
owned(visibility: .locked),
owned(visibility: .locked),
],
);
final message = verify(() => toast.success(captureAny())).captured.single as String;

View File

@@ -28,14 +28,12 @@ void main() {
];
group('PartnerAddAction', () {
const action = PartnerAddAction();
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(
context,
(scope) => PartnerAddAction(scope: scope),
overrides: overrides(candidates: [candidate]),
);
await tester.runAction(context, action, overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
@@ -44,11 +42,7 @@ void main() {
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(
context,
(scope) => PartnerAddAction(scope: scope),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.runAction(context, action, overrides: overrides(candidates: [UserFactory.create()]));
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
@@ -57,13 +51,12 @@ void main() {
});
group('PartnerRemoveAction', () {
PartnerRemoveAction removeFor(User partner) =>
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name);
testWidgets('deletes the partner after confirmation', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
(scope) => PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
overrides: overrides(),
);
await tester.runAction(context, removeFor(partner), overrides: overrides());
await tester.tap(find.byType(TextButton).last); // confirm
await tester.pumpAndSettle();
@@ -72,11 +65,7 @@ void main() {
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
(scope) => PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
overrides: overrides(),
);
await tester.runAction(context, removeFor(partner), overrides: overrides());
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();

View File

@@ -24,30 +24,24 @@ void main() {
RemoteAsset remote() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
const action = RemoveFromAlbumAction(albumId: 'album');
group('RemoveFromAlbumAction', () {
testWidgets('removes the selected remote assets from the album', (tester) async {
final first = remote();
final second = remote();
final albumId = 'album';
final action = await tester.pumpTestAction(
context,
(scope) => RemoveFromAlbumAction(assets: [first, second], albumId: albumId, scope: scope),
);
final resolved = await tester.runAction(context, action, assets: [first, second]);
expect(action.icon, Icons.remove_circle_outline);
expect(action.label, StaticTranslations.instance.remove_from_album);
verify(() => albumService.removeAssets(albumId: albumId, assetIds: [first.id, second.id])).called(1);
expect(resolved!.icon, Icons.remove_circle_outline);
expect(resolved.label, StaticTranslations.instance.remove_from_album);
verify(() => albumService.removeAssets(albumId: 'album', assetIds: [first.id, second.id])).called(1);
});
testWidgets('reports the removed count through the toast repository', (tester) async {
final toast = context.repository.toast;
final albumId = 'album';
when(context.service.album.removeAssets).thenAnswer((_) async => 2);
await tester.pumpTestAction(
context,
(scope) => RemoveFromAlbumAction(assets: [remote(), remote()], albumId: albumId, scope: scope),
);
await tester.runAction(context, action, assets: [remote(), remote()]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.remove_from_album_action_prompt(count: 2));

View File

@@ -24,11 +24,13 @@ void main() {
RemoteAsset owned({bool trashed = true}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null);
const action = RestoreAction();
group('RestoreAction', () {
testWidgets('restores the eligible owned trashed assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [asset], scope: scope));
await tester.runAction(context, action, assets: [asset]);
verify(() => assetService.restoreTrash([asset.id])).called(1);
});
@@ -37,7 +39,7 @@ void main() {
final mine = owned();
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [mine, theirs], scope: scope));
await tester.runAction(context, action, assets: [mine, theirs]);
verify(() => assetService.restoreTrash([mine.id])).called(1);
});
@@ -46,7 +48,7 @@ void main() {
final trashed = owned();
final live = owned(trashed: false);
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [trashed, live], scope: scope));
await tester.runAction(context, action, assets: [trashed, live]);
verify(() => assetService.restoreTrash([trashed.id])).called(1);
});
@@ -55,7 +57,7 @@ void main() {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [first, second], scope: scope));
await tester.runAction(context, action, assets: [first, second]);
verify(() => assetService.restoreTrash([first.id, second.id])).called(1);
});
@@ -63,7 +65,7 @@ void main() {
testWidgets('reports success through the toast repository with the restored count', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [owned(), owned()], scope: scope));
await tester.runAction(context, action, assets: [owned(), owned()]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.assets_restored_count(count: 2));

View File

@@ -24,28 +24,22 @@ void main() {
RemoteAsset remote() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
const action = SetAlbumCoverAction(albumId: 'album');
group('SetAlbumCoverAction', () {
testWidgets('sets the selected asset as the album cover', (tester) async {
final asset = remote();
const albumId = 'album';
final action = await tester.pumpTestAction(
context,
(scope) => SetAlbumCoverAction(assets: [asset], albumId: albumId, scope: scope),
);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(action.icon, Icons.image_outlined);
expect(action.label, StaticTranslations.instance.set_as_album_cover);
verify(() => albumService.updateAlbum(albumId, thumbnailAssetId: asset.id)).called(1);
expect(resolved!.icon, Icons.image_outlined);
expect(resolved.label, StaticTranslations.instance.set_as_album_cover);
verify(() => albumService.updateAlbum('album', thumbnailAssetId: asset.id)).called(1);
});
testWidgets('is hidden unless exactly one remote asset is selected', (tester) async {
const albumId = 'album';
final action = await tester.pumpActionButton(
context,
(scope) => SetAlbumCoverAction(assets: [remote(), remote()], albumId: albumId, scope: scope),
);
final resolved = await tester.resolveAction(context, action, assets: [remote(), remote()]);
expect(action.isVisible, isFalse);
expect(resolved, isNull);
});
});
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/config/share_config.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../repository.mocks.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetMediaRepository mediaRepo;
setUp(() async {
context = await PresentationContext.create();
mediaRepo = context.repository.assetMedia.api;
});
tearDown(() {
context.dispose();
});
List<Override> savedQuality(ShareAssetType fileType) => [
appConfigProvider.overrideWithValue(defaultConfig.copyWith(share: ShareConfig(fileType: fileType))),
];
RemoteAsset asset({AssetType type = .image}) => RemoteAssetFactory.create(type: type);
const action = ShareAction();
group('ShareAction', () {
testWidgets('visible when there are assets to share', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
expect(resolved, isNotNull);
expect(resolved!.onSecondaryAction, isNotNull);
expect(resolved.label, StaticTranslations.instance.share);
});
testWidgets('hidden when the selection is empty', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: const []);
expect(resolved, isNull);
});
testWidgets('uses the Android share icon on Android', (tester) async {
debugDefaultTargetPlatformOverride = .android;
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
expect(resolved!.icon, Icons.share_rounded);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('uses the iOS share icon on iOS', (tester) async {
debugDefaultTargetPlatformOverride = .iOS;
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
expect(resolved!.icon, Icons.ios_share_rounded);
debugDefaultTargetPlatformOverride = null;
});
});
group('quality picker', () {
testWidgets('offers both original and preview for an image', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
unawaited(resolved!.onSecondaryAction!());
await tester.pumpAndSettle();
expect(find.text(StaticTranslations.instance.share_original), findsOneWidget);
expect(find.text(StaticTranslations.instance.share_preview), findsOneWidget);
await tester.tap(find.text(StaticTranslations.instance.cancel));
await tester.pumpAndSettle();
});
testWidgets('offers only original for a video', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [asset(type: .video)]);
unawaited(resolved!.onSecondaryAction!());
await tester.pumpAndSettle();
expect(find.text(StaticTranslations.instance.share_original), findsOneWidget);
expect(find.text(StaticTranslations.instance.share_preview), findsNothing);
await tester.tap(find.text(StaticTranslations.instance.cancel));
await tester.pumpAndSettle();
});
});
group('onAction', () {
testWidgets('shares the assets at the quality saved in settings', (tester) async {
final target = asset();
await tester.runAction(context, action, assets: [target], overrides: savedQuality(.preview));
await tester.pump(const .new(milliseconds: 500));
verify(
() => mediaRepo.shareAssets(
any(that: contains(target)),
any(),
fileType: .preview,
cancelCompleter: any(named: 'cancelCompleter'),
onAssetDownloadProgress: any(named: 'onAssetDownloadProgress'),
),
).called(1);
await tester.pumpAndSettle();
});
});
}

View File

@@ -24,41 +24,34 @@ void main() {
RemoteAsset owned({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId);
const action = StackAction();
group('StackAction', () {
testWidgets('stacks the eligible owned assets', (tester) async {
final first = owned();
final second = owned();
final action = await tester.pumpTestAction(
context,
(scope) => StackAction(assets: [first, second], scope: scope),
);
expect(action.icon, Icons.filter_none_rounded);
expect(action.label, StaticTranslations.instance.stack);
final resolved = await tester.runAction(context, action, assets: [first, second]);
expect(resolved!.icon, Icons.filter_none_rounded);
expect(resolved.label, StaticTranslations.instance.stack);
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('unstacks the eligible owned assets', (tester) async {
final asset = owned(stackId: 'stack');
final action = await tester.pumpTestAction(context, (scope) => StackAction(assets: [asset], scope: scope));
expect(action.icon, Icons.layers_clear_outlined);
expect(action.label, StaticTranslations.instance.unstack);
final resolved = await tester.runAction(context, action, assets: [asset]);
expect(resolved!.icon, Icons.layers_clear_outlined);
expect(resolved.label, StaticTranslations.instance.unstack);
verify(() => assetService.unstack(['stack'])).called(1);
});
testWidgets('prioritizes stack when the owned selection is mixed', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
final action = await tester.pumpTestAction(
context,
(scope) => StackAction(assets: [first, second], scope: scope),
);
expect(action.label, StaticTranslations.instance.stack);
final resolved = await tester.runAction(context, action, assets: [first, second]);
expect(resolved!.label, StaticTranslations.instance.stack);
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
@@ -67,7 +60,7 @@ void main() {
final other = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, (scope) => StackAction(assets: [mine, other, theirs], scope: scope));
await tester.runAction(context, action, assets: [mine, other, theirs]);
verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1);
});
@@ -76,7 +69,7 @@ void main() {
final first = owned(stackId: 'stack-1');
final second = owned(stackId: 'stack-2');
await tester.pumpTestAction(context, (scope) => StackAction(assets: [first, second], scope: scope));
await tester.runAction(context, action, assets: [first, second]);
verify(() => assetService.unstack(['stack-1', 'stack-2'])).called(1);
});
@@ -84,7 +77,7 @@ void main() {
testWidgets('reports the stacked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => StackAction(assets: [owned(), owned()], scope: scope));
await tester.runAction(context, action, assets: [owned(), owned()]);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.stacked_assets_count(count: 2));
@@ -93,15 +86,13 @@ void main() {
testWidgets('reports the unstacked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
await tester.runAction(
context,
(scope) => StackAction(
assets: [
owned(stackId: 'stack-1'),
owned(stackId: 'stack-2'),
],
scope: scope,
),
action,
assets: [
owned(stackId: 'stack-1'),
owned(stackId: 'stack-2'),
],
);
final message = verify(() => toast.success(captureAny())).captured.single as String;

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/tag.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../repository.mocks.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockTagService tagService;
setUp(() async {
context = await PresentationContext.create();
tagService = context.service.tag.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
const action = TagAction();
group('TagAction', () {
testWidgets('visible with an owned remote asset', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [owned()]);
expect(resolved, isNotNull);
expect(resolved!.icon, Icons.sell_outlined);
expect(resolved.label, StaticTranslations.instance.control_bottom_app_bar_add_tags);
});
testWidgets('hidden for an asset owned by someone else', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
expect(resolved, isNull);
});
testWidgets('applies the selected tags and toasts the count', (tester) async {
final asset = owned();
final toast = context.repository.toast;
when(context.service.tag.bulkTagAssets).thenAnswer((_) async => 2);
final scope = await tester.actionScope(context, assets: [asset]);
await action.applyTags(scope, [asset.id], selected: {'tag'}, created: const {});
verify(() => tagService.bulkTagAssets([asset.id], ['tag'])).called(1);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.tagged_assets(count: 2));
});
testWidgets('creates new tags before applying them', (tester) async {
final asset = owned();
when(() => tagService.upsertTags(['new'])).thenAnswer((_) async => const [Tag(id: 'tag', value: 'new')]);
when(context.service.tag.bulkTagAssets).thenAnswer((_) async => 1);
final scope = await tester.actionScope(context, assets: [asset]);
await action.applyTags(scope, [asset.id], selected: const {}, created: {'new'});
verify(() => tagService.upsertTags(['new'])).called(1);
verify(() => tagService.bulkTagAssets([asset.id], ['tag'])).called(1);
});
testWidgets('does nothing when no tags are chosen', (tester) async {
final asset = owned();
final scope = await tester.actionScope(context, assets: [asset]);
await action.applyTags(scope, [asset.id], selected: const {}, created: const {});
verifyNever(context.service.tag.bulkTagAssets);
});
});
}

View File

@@ -2,7 +2,6 @@ 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';
@@ -10,21 +9,31 @@ import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({required super.scope, bool visible = true, this.error})
: super(icon: Icons.bolt, label: 'fake', isVisible: visible);
_FakeAction({this.visible = true, this.error});
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
Future<void> onAction() async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
WidgetAction? resolve(ActionScope scope) {
if (!visible) {
return null;
}
return .new(
icon: Icons.bolt,
label: 'fake',
onAction: () async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
}
},
);
}
}
@@ -47,28 +56,31 @@ void main() {
),
];
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
late ActionScope scope;
Future<(WidgetRef, ProviderContainer)> pumpScope(WidgetTester tester) async {
late WidgetRef widgetRef;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
widgetRef = ref;
container = ProviderScope.containerOf(innerContext, listen: false);
return const SizedBox.shrink();
},
),
overrides: overrides(),
);
return (scope, container);
return (widgetRef, container);
}
ActionScope scopeOf(WidgetRef ref) => ActionScope(assets: const [], authUser: context.currentUser, ref: ref);
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final (scope, container) = await pumpScope(tester);
final inner = _FakeAction(scope: scope);
await TimelineAction(action: inner).onAction();
final (ref, container) = await pumpScope(tester);
final inner = _FakeAction();
await TimelineAction(action: inner).resolve(scopeOf(ref))!.onAction();
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
@@ -77,23 +89,19 @@ void main() {
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final (scope, container) = await pumpScope(tester);
final inner = _FakeAction(scope: scope, error: error);
final (ref, container) = await pumpScope(tester);
final inner = _FakeAction(error: error);
await expectLater(TimelineAction(action: inner).onAction(), throwsA(same(error)));
await expectLater(TimelineAction(action: inner).resolve(scopeOf(ref))!.onAction(), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpActionButton(
context,
(scope) => TimelineAction(action: _FakeAction(scope: scope, visible: false)),
);
final resolved = await tester.resolveAction(context, TimelineAction(action: _FakeAction(visible: false)));
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
expect(resolved, isNull);
});
});
}

View File

@@ -0,0 +1,115 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/upload.action.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/local_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockForegroundUploadService uploadService;
setUp(() async {
context = await PresentationContext.create();
uploadService = context.service.upload;
});
tearDown(() {
context.dispose();
});
void whenUpload(void Function(UploadCallbacks callbacks) simulate) {
when(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((inv) async => simulate(inv.namedArguments[#callbacks] as UploadCallbacks));
}
const action = UploadAction(source: ActionSource.timeline);
group('UploadAction', () {
testWidgets('visible with a local asset', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: [LocalAssetFactory.create()]);
expect(resolved, isNotNull);
expect(resolved!.icon, Icons.backup_outlined);
expect(resolved.label, StaticTranslations.instance.upload);
});
testWidgets('hidden without any local asset', (tester) async {
final resolved = await tester.resolveAction(context, action, assets: const []);
expect(resolved, isNull);
});
testWidgets('uploads the assets with no error toast on success', (tester) async {
final asset = LocalAssetFactory.create();
final toast = context.repository.toast;
whenUpload((cb) => cb.onSuccess?.call(asset.id, 'remote-1'));
final scope = await tester.actionScope(context, assets: [asset]);
await action.upload(scope, [asset]);
await tester.pump(const Duration(seconds: 2)); // flush the delayed progress clear
verify(
() => uploadService.uploadManual(
any(that: contains(asset)),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).called(1);
verifyNever(() => toast.error(any()));
});
testWidgets('shows an error toast when an asset fails', (tester) async {
final asset = LocalAssetFactory.create();
final toast = context.repository.toast;
whenUpload((cb) => cb.onError?.call(asset.id, 'boom'));
final scope = await tester.actionScope(context, assets: [asset]);
await action.upload(scope, [asset]);
await tester.pump(const Duration(seconds: 2));
verify(() => toast.error(StaticTranslations.instance.scaffold_body_error_occurred)).called(1);
});
testWidgets('shows the progress dialog when launched from the viewer', (tester) async {
final asset = LocalAssetFactory.create();
final gate = Completer<void>();
when(
() => uploadService.uploadManual(
any(),
cancelToken: any(named: 'cancelToken'),
callbacks: any(named: 'callbacks'),
),
).thenAnswer((_) => gate.future);
final resolved = await tester.resolveAction(
context,
const UploadAction(source: ActionSource.viewer),
assets: [asset],
);
unawaited(resolved!.onAction());
await tester.pump();
expect(find.text(StaticTranslations.instance.uploading), findsOneWidget);
gate.complete();
await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.text(StaticTranslations.instance.uploading), findsNothing);
});
});
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -5,21 +7,26 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/tag.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/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
@@ -51,13 +58,17 @@ class PresentationContext {
assetServiceProvider.overrideWithValue(service.asset.service),
remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo),
remoteExifRepositoryProvider.overrideWithValue(repository.remoteExif.repo),
assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api),
downloadRepositoryProvider.overrideWithValue(repository.download.repo),
tagServiceProvider.overrideWithValue(service.tag.service),
backgroundSyncProvider.overrideWithValue(service.backgroundSync),
partnerServiceProvider.overrideWithValue(service.partner.service),
remoteAlbumServiceProvider.overrideWithValue(service.album.service),
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
gCastServiceProvider.overrideWithValue(MockGCastService()),
foregroundUploadServiceProvider.overrideWithValue(service.upload),
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier()),
toastRepositoryProvider.overrideWithValue(repository.toast),
gCastServiceProvider.overrideWithValue(MockGCastService()),
];
static Future<PresentationContext> create() async {
@@ -111,34 +122,55 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<BaseAction> pumpTestAction(
PresentationContext context,
BaseAction Function(ActionScope scope) build, {
Future<ActionScope> actionScope(
PresentationContext context, {
Iterable<BaseAsset> assets = const [],
List<Override> overrides = const [],
}) async {
final action = await pumpActionButton(context, build, overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
return action;
}
Future<BaseAction> pumpActionButton(
PresentationContext context,
BaseAction Function(ActionScope scope) build, {
List<Override> overrides = const [],
}) async {
late BaseAction action;
late ActionScope scope;
await pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
action = build(ActionScope(context: innerContext, ref: ref, authUser: context.currentUser));
return ActionIconButtonWidget(action: action);
builder: (_, ref, _) {
scope = ActionScope(assets: assets, authUser: context.currentUser, ref: ref);
return const SizedBox.shrink();
},
),
overrides: overrides,
);
return action;
return scope;
}
Future<WidgetAction?> resolveAction(
PresentationContext context,
BaseAction action, {
Iterable<BaseAsset> assets = const [],
List<Override> overrides = const [],
}) async {
WidgetAction? resolved;
await pumpTestWidget(
context,
Consumer(
builder: (_, ref, _) {
resolved = action.resolve(ActionScope(assets: assets, authUser: context.currentUser, ref: ref));
return const SizedBox.shrink();
},
),
overrides: overrides,
);
return resolved;
}
Future<WidgetAction?> runAction(
PresentationContext context,
BaseAction action, {
Iterable<BaseAsset> assets = const [],
List<Override> overrides = const [],
}) async {
final resolved = await resolveAction(context, action, assets: assets, overrides: overrides);
unawaited(resolved?.onAction());
await pumpAndSettle();
return resolved;
}
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {

View File

@@ -979,7 +979,7 @@ void main() {
),
_ => _buttonContext(asset: asset),
};
built.add(buttonType.buildButton(buttonContext, buildContext, ref));
built.add(buttonType.buildButton(buttonContext, buildContext));
}
return const SizedBox.shrink();
},
@@ -1008,7 +1008,7 @@ void main() {
presentation,
Consumer(
builder: (buildContext, ref, _) {
widgets = ActionButtonBuilder.build(context, buildContext, ref);
widgets = ActionButtonBuilder.build(context, buildContext);
return const SizedBox.shrink();
},
),