mirror of
https://github.com/immich-app/immich.git
synced 2026-07-22 05:44:03 +03:00
Compare commits
4 Commits
feat/uploa
...
refactor/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f4cd398d | ||
|
|
da0742ab8c | ||
|
|
a5a34c1871 | ||
|
|
aed5c90a16 |
@@ -16,7 +16,6 @@ import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||
@@ -24,13 +23,13 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/locale_provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/deep_link.service.dart';
|
||||
@@ -272,8 +271,9 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
|
||||
builder: (context, child) => ImmichTranslationProvider(
|
||||
translations: ImmichTranslations(
|
||||
submit: "submit".t(context: context),
|
||||
password: "password".t(context: context),
|
||||
submit: context.t.submit,
|
||||
password: context.t.password,
|
||||
undo: context.t.undo,
|
||||
),
|
||||
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
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/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
class ActionScope {
|
||||
final BuildContext context;
|
||||
final WidgetRef ref;
|
||||
final UserDto authUser;
|
||||
|
||||
const ActionScope({required this.context, required this.ref, required this.authUser});
|
||||
|
||||
factory ActionScope.from(BuildContext context, WidgetRef ref) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseAction {
|
||||
final ActionScope scope;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isVisible;
|
||||
const BaseAction();
|
||||
|
||||
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
|
||||
IconData get icon;
|
||||
|
||||
Future<void> onAction();
|
||||
String label(BuildContext context);
|
||||
|
||||
Future<void> Function()? get onSecondaryAction => null;
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => true;
|
||||
|
||||
@protected
|
||||
@visibleForTesting
|
||||
UserDto currentUser(WidgetRef ref) => ref.read(currentUserProvider)!;
|
||||
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets);
|
||||
|
||||
Future<void> Function(WidgetRef ref, Iterable<BaseAsset> assets)? get onSecondaryAction => null;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,29 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.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/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.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';
|
||||
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;
|
||||
final FutureOr<void> Function()? onSecondaryAction;
|
||||
|
||||
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction, this.onSecondaryAction});
|
||||
}
|
||||
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> _guard(Future<void> Function() handler) async {
|
||||
try {
|
||||
@@ -30,39 +35,35 @@ class _ActionWidget extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> Function() get _onAction =>
|
||||
() => _guard(action.onAction);
|
||||
|
||||
Future<void> Function()? get _onSecondaryAction {
|
||||
final onSecondaryAction = action.onSecondaryAction;
|
||||
if (onSecondaryAction == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return () => _guard(onSecondaryAction);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!action.isVisible) {
|
||||
final assets = source?.select(ref) ?? const <BaseAsset>[];
|
||||
final authUser = ref.watch(currentUserProvider);
|
||||
if (authUser == null || !action.isVisible(ref, assets)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return builder(
|
||||
.new(icon: action.icon, label: action.label, onAction: _onAction, onSecondaryAction: _onSecondaryAction),
|
||||
);
|
||||
final secondaryAction = action.onSecondaryAction;
|
||||
return builder((
|
||||
icon: action.icon,
|
||||
label: action.label(context),
|
||||
onAction: () => _guard(() => action.onAction(ref, assets)),
|
||||
onSecondaryAction: secondaryAction == null ? null : () => _guard(() => secondaryAction(ref, assets)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
source: source,
|
||||
builder: (ctx) =>
|
||||
ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, onLongPress: ctx.onSecondaryAction, variant: variant),
|
||||
);
|
||||
@@ -70,13 +71,15 @@ class ActionIconButtonWidget extends StatelessWidget {
|
||||
|
||||
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,
|
||||
source: source,
|
||||
builder: (ctx) => ImmichTextButton(
|
||||
labelText: ctx.label,
|
||||
icon: ctx.icon,
|
||||
@@ -89,12 +92,14 @@ class ActionButtonWidget extends StatelessWidget {
|
||||
|
||||
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,
|
||||
source: source,
|
||||
builder: (ctx) => ImmichColumnButton(
|
||||
icon: ctx.icon,
|
||||
label: ctx.label,
|
||||
@@ -106,12 +111,36 @@ class ActionColumnButtonWidget extends StatelessWidget {
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
class TimelineSheetActionWidget extends StatelessWidget {
|
||||
final BaseAction action;
|
||||
|
||||
const TimelineSheetActionWidget({super.key, required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ActionColumnButtonWidget(
|
||||
source: .timeline,
|
||||
action: TimelineAction(action: action),
|
||||
);
|
||||
}
|
||||
|
||||
extension 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 [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,41 +7,73 @@ 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
|
||||
IconData get icon => Icons.archive_outlined;
|
||||
|
||||
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);
|
||||
@override
|
||||
String label(context) => context.t.archive;
|
||||
|
||||
return ArchiveAction._(
|
||||
assetIds: assetIds,
|
||||
archive: archive,
|
||||
scope: scope,
|
||||
icon: archive ? Icons.archive_outlined : Icons.unarchive_outlined,
|
||||
label: archive ? scope.context.t.archive : scope.context.t.unarchive,
|
||||
isVisible: assetIds.isNotEmpty,
|
||||
);
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(ref, assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id).archived(isArchived: false);
|
||||
|
||||
@override
|
||||
bool isVisible(ref, assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(ref, assets) async {
|
||||
final context = ref.context;
|
||||
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
await assetService.update(ids, visibility: const .some(.archive));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(toastRepositoryProvider)
|
||||
.success(
|
||||
context.t.archive_action_prompt(count: ids.length),
|
||||
toast: .new(onUndo: () async => await assetService.update(ids, visibility: const .some(.timeline))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UnarchiveAction extends BaseAction {
|
||||
const UnarchiveAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.unarchive_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.unarchive;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(ref, assets) {
|
||||
final owned = AssetFilter(assets).owned(currentUser(ref).id);
|
||||
return owned.archived(isArchived: false).isEmpty ? owned : const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
bool isVisible(ref, assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(archive ? .archive : .timeline));
|
||||
final message = archive
|
||||
? context.t.archive_action_prompt(count: assetIds.length)
|
||||
: context.t.unarchive_action_prompt(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
@override
|
||||
Future<void> onAction(ref, assets) async {
|
||||
final context = ref.context;
|
||||
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
await assetService.update(ids, visibility: const .some(.timeline));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(toastRepositoryProvider)
|
||||
.success(
|
||||
context.t.unarchive_action_prompt(count: ids.length),
|
||||
toast: .new(onUndo: () async => await assetService.update(ids, visibility: const .some(.archive))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +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/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/stack.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/tag.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/upload.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;
|
||||
final DownloadAction download;
|
||||
final TagAction tag;
|
||||
final UploadAction upload;
|
||||
|
||||
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,
|
||||
required this.download,
|
||||
required this.tag,
|
||||
required this.upload,
|
||||
});
|
||||
|
||||
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),
|
||||
download: DownloadAction(assets: assets, scope: scope),
|
||||
tag: TagAction(assets: assets, scope: scope),
|
||||
upload: UploadAction(assets: assets, scope: scope),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -9,17 +10,19 @@ 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)));
|
||||
IconData get icon => Icons.help_outline_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.troubleshoot;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
assets.singleOrNull != null && ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async =>
|
||||
unawaited(ref.context.pushRoute(AssetTroubleshootRoute(asset: assets.single)));
|
||||
}
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
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/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()));
|
||||
}
|
||||
IconData get icon => Icons.cast_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.cast;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async =>
|
||||
unawaited(showDialog(context: ref.context, builder: (_) => const CastDialog()));
|
||||
}
|
||||
|
||||
class UnCastAction extends BaseAction {
|
||||
const UnCastAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.cast_connected_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.cast;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async =>
|
||||
unawaited(showDialog(context: ref.context, builder: (_) => const CastDialog()));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -11,88 +12,83 @@ import 'package:immich_mobile/services/cleanup.service.dart';
|
||||
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;
|
||||
class TrashAction extends BaseAction {
|
||||
const TrashAction();
|
||||
|
||||
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);
|
||||
@override
|
||||
IconData get icon => Icons.delete_outline;
|
||||
|
||||
factory DeleteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final ActionScope(:ref) = scope;
|
||||
@override
|
||||
String label(context) => context.t.trash;
|
||||
|
||||
final localIds = <String>[];
|
||||
final ownedRemote = <RemoteAsset>[];
|
||||
for (final asset in assets) {
|
||||
if (asset.localId case final id?) {
|
||||
localIds.add(id);
|
||||
}
|
||||
|
||||
if (asset case final RemoteAsset remote when remote.ownerId == scope.authUser.id) {
|
||||
ownedRemote.add(remote);
|
||||
}
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final (:remoteAssets, localIds: _) = groupAssets(currentUser(ref).id, assets);
|
||||
if (remoteAssets.isEmpty || isPermanentDelete(remoteAssets)) {
|
||||
return false;
|
||||
}
|
||||
final remoteIds = ownedRemote.map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
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);
|
||||
|
||||
return ._(
|
||||
localIds: localIds,
|
||||
remoteIds: remoteIds,
|
||||
trash: trash,
|
||||
scope: scope,
|
||||
isVisible: remoteIds.isNotEmpty || localIds.isNotEmpty,
|
||||
);
|
||||
return ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
final toast = ref.read(toastRepositoryProvider);
|
||||
|
||||
// Local-only
|
||||
// Single prompt on iOS & Android (without MANAGE_MEDIA)
|
||||
// No prompt on Android (with MANAGE_MEDIA)
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final (:remoteAssets, :localIds) = groupAssets(currentUser(ref).id, assets);
|
||||
final remoteIds = remoteAssets.map((asset) => asset.id);
|
||||
if (remoteIds.isEmpty) {
|
||||
if (localIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = await cleanupLocalAssets(assetIds: localIds, scope: scope);
|
||||
if (!context.mounted || count <= 0) {
|
||||
return;
|
||||
}
|
||||
toast.success(context.t.cleanup_deleted_assets(count: count));
|
||||
return;
|
||||
}
|
||||
|
||||
// Trash
|
||||
// No prompt on Android (with MANAGE_MEDIA)
|
||||
// Single prompt on iOS & Android (without MANAGE_MEDIA)
|
||||
// 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);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await ref.read(assetServiceProvider).trash(remoteIds);
|
||||
toast.success(context.t.trash_action_prompt(count: remoteIds.length));
|
||||
if (localIds.isNotEmpty) {
|
||||
await cleanupLocalAssets(ref, assetIds: localIds);
|
||||
}
|
||||
|
||||
final ids = remoteIds.toList(growable: false);
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
await assetService.trash(ids);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(toastRepositoryProvider)
|
||||
.success(
|
||||
context.t.trash_action_prompt(count: ids.length),
|
||||
toast: .new(onUndo: () async => await assetService.restoreTrash(ids)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeletePermanentlyAction extends BaseAction {
|
||||
const DeletePermanentlyAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.delete_outline;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.delete;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final (:remoteAssets, localIds: _) = groupAssets(currentUser(ref).id, assets);
|
||||
if (remoteAssets.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isPermanentDelete(remoteAssets) ||
|
||||
ref.watch(serverInfoProvider.select((state) => !state.serverFeatures.trash));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final (:remoteAssets, :localIds) = groupAssets(currentUser(ref).id, assets);
|
||||
final remoteIds = remoteAssets.map((asset) => asset.id).toList(growable: false);
|
||||
if (remoteIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Permanent delete
|
||||
// Single prompt on Android (with MANAGE_MEDIA)
|
||||
// Double prompts on iOS & Android (without MANAGE_MEDIA)
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
@@ -104,51 +100,91 @@ 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);
|
||||
if (localIds.isNotEmpty) {
|
||||
await cleanupLocalAssets(ref, assetIds: localIds, requestPrompt: false);
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(context.t.delete_permanently_action_prompt(count: remoteIds.length));
|
||||
ref.read(toastRepositoryProvider).success(context.t.delete_permanently_action_prompt(count: remoteIds.length));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
class DeleteLocalAction extends BaseAction {
|
||||
const DeleteLocalAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
final count = await cleanupLocalAssets(assetIds: assetIds, scope: scope);
|
||||
IconData get icon => Icons.delete_outline;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.delete;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final (:remoteAssets, :localIds) = groupAssets(currentUser(ref).id, assets);
|
||||
return remoteAssets.isEmpty && localIds.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final (:localIds, remoteAssets: _) = groupAssets(currentUser(ref).id, assets);
|
||||
if (localIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = await cleanupLocalAssets(ref, assetIds: localIds);
|
||||
if (!context.mounted || count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(toastRepositoryProvider).success(context.t.cleanup_deleted_assets(count: count));
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<int> cleanupLocalAssets({
|
||||
required List<String> assetIds,
|
||||
required ActionScope scope,
|
||||
bool requestPrompt = true,
|
||||
}) async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
({Iterable<String> localIds, Iterable<RemoteAsset> remoteAssets}) groupAssets(
|
||||
String ownerId,
|
||||
Iterable<BaseAsset> assets,
|
||||
) => (localIds: assets.map((a) => a.localId).nonNulls, remoteAssets: AssetFilter(assets).owned(ownerId));
|
||||
|
||||
@visibleForTesting
|
||||
bool isPermanentDelete(Iterable<RemoteAsset> remoteAssets) =>
|
||||
remoteAssets.every((asset) => asset.isTrashed || asset.isLocked);
|
||||
|
||||
class CleanupLocalAction extends BaseAction {
|
||||
const CleanupLocalAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.no_cell_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.control_bottom_app_bar_delete_from_local;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<String> assetsForAction(Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).backedUp().map((asset) => asset.localId).nonNulls;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
|
||||
final count = await cleanupLocalAssets(ref, assetIds: assetsForAction(assets));
|
||||
if (!context.mounted || count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(toastRepositoryProvider).success(context.t.cleanup_deleted_assets(count: count));
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<int> cleanupLocalAssets(WidgetRef ref, {required Iterable<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)
|
||||
@@ -173,5 +209,5 @@ Future<int> cleanupLocalAssets({
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await ref.read(cleanupServiceProvider).deleteLocalAssets(assetIds);
|
||||
return await ref.read(cleanupServiceProvider).deleteLocalAssets(assetIds.toList(growable: false));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -9,22 +10,24 @@ import 'package:immich_mobile/repositories/download.repository.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class DownloadAction extends BaseAction {
|
||||
final List<RemoteAsset> assets;
|
||||
|
||||
DownloadAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.download, label: scope.context.t.download);
|
||||
|
||||
factory DownloadAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final remote = AssetFilter(assets).remote().toList(growable: false);
|
||||
return ._(assets: remote, scope: scope, isVisible: remote.isNotEmpty);
|
||||
}
|
||||
const DownloadAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref) = scope;
|
||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||
IconData get icon => Icons.download;
|
||||
|
||||
await ref.read(downloadRepositoryProvider).downloadAllAssets(assets);
|
||||
@override
|
||||
String label(context) => context.t.download;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(Iterable<BaseAsset> assets) => AssetFilter(assets).remote();
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final backgroundSync = ref.read(backgroundSyncProvider);
|
||||
await ref.read(downloadRepositoryProvider).downloadAllAssets(assetsForAction(assets).toList(growable: false));
|
||||
|
||||
unawaited(
|
||||
Future.delayed(const Duration(seconds: 1), () async {
|
||||
|
||||
@@ -17,26 +17,30 @@ import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class EditAssetAction extends BaseAction {
|
||||
final Iterable<RemoteAsset> assets;
|
||||
const EditAssetAction();
|
||||
|
||||
EditAssetAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.tune, label: scope.context.t.edit);
|
||||
@override
|
||||
IconData get icon => Icons.tune;
|
||||
|
||||
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);
|
||||
@override
|
||||
String label(context) => context.t.edit;
|
||||
|
||||
return EditAssetAction._(assets: editable, scope: scope, isVisible: isSupported && editable.length == 1);
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id).where((asset) => asset.isEditable);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final supported = ref.watch(serverInfoProvider).serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
|
||||
return supported && assetsForAction(ref, assets).singleOrNull != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final asset = assetsForAction(ref, assets).single;
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -10,44 +11,46 @@ 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;
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
const EditDateTimeAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
IconData get icon => Icons.edit_calendar_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.control_bottom_app_bar_edit_time;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
|
||||
final owned = assetsForAction(ref, assets);
|
||||
final ids = owned.map((asset) => asset.id).toList(growable: false);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime? initialDate;
|
||||
String? timeZone;
|
||||
Duration? offset;
|
||||
|
||||
final seed = origin;
|
||||
if (seed != null) {
|
||||
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||
final seed = owned.first;
|
||||
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||
|
||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||
DateTime dt = seed.createdAt.toLocal();
|
||||
offset = dt.timeZoneOffset;
|
||||
if (exif?.dateTimeOriginal != null) {
|
||||
timeZone = exif!.timeZone;
|
||||
(dt, offset) = applyTimezoneOffset(dateTime: exif.dateTimeOriginal!, timeZone: exif.timeZone);
|
||||
}
|
||||
initialDate = dt;
|
||||
// Use EXIF timezone information if available (matching web app and display behavior)
|
||||
DateTime dt = seed.createdAt.toLocal();
|
||||
offset = dt.timeZoneOffset;
|
||||
if (exif?.dateTimeOriginal != null) {
|
||||
timeZone = exif!.timeZone;
|
||||
(dt, offset) = applyTimezoneOffset(dateTime: exif.dateTimeOriginal!, timeZone: exif.timeZone);
|
||||
}
|
||||
initialDate = dt;
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
@@ -63,15 +66,14 @@ class EditDateTimeAction extends BaseAction {
|
||||
return;
|
||||
}
|
||||
|
||||
await save(dateTime);
|
||||
await save(ref, 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(WidgetRef ref, List<String> ids, String dateTime) async {
|
||||
final context = ref.context;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,45 @@
|
||||
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/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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/location_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class EditLocationAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
final RemoteAsset? origin;
|
||||
|
||||
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}) {
|
||||
final owned = assets
|
||||
.whereType<RemoteAsset>()
|
||||
.where((asset) => asset.ownerId == scope.authUser.id)
|
||||
.toList(growable: false);
|
||||
|
||||
return EditLocationAction._(
|
||||
assetIds: owned.map((asset) => asset.id).toList(growable: false),
|
||||
origin: owned.length == 1 ? owned.first : null,
|
||||
scope: scope,
|
||||
icon: Icons.edit_location_alt_outlined,
|
||||
label: scope.context.t.control_bottom_app_bar_edit_location,
|
||||
isVisible: owned.isNotEmpty,
|
||||
);
|
||||
}
|
||||
const EditLocationAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
IconData get icon => Icons.edit_location_alt_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.control_bottom_app_bar_edit_location;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final owned = assetsForAction(ref, assets);
|
||||
final ids = owned.map((asset) => asset.id).toList(growable: false);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final context = ref.context;
|
||||
|
||||
LatLng? initialLatLng;
|
||||
final seed = origin;
|
||||
if (seed != null) {
|
||||
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(seed.id);
|
||||
if (exif?.latitude != null && exif?.longitude != null) {
|
||||
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
|
||||
}
|
||||
final exif = await ref.read(remoteAssetRepositoryProvider).getExif(owned.first.id);
|
||||
if (exif?.latitude != null && exif?.longitude != null) {
|
||||
initialLatLng = LatLng(exif!.latitude!, exif.longitude!);
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
@@ -59,15 +51,14 @@ class EditLocationAction extends BaseAction {
|
||||
return;
|
||||
}
|
||||
|
||||
await save(location);
|
||||
await save(ref, 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(WidgetRef ref, List<String> ids, LatLng location) async {
|
||||
final context = ref.context;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -7,41 +8,61 @@ 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
|
||||
IconData get icon => Icons.favorite_border_rounded;
|
||||
|
||||
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);
|
||||
@override
|
||||
String label(context) => context.t.favorite;
|
||||
|
||||
return FavoriteAction._(
|
||||
assetIds: assetIds,
|
||||
favorite: favorite,
|
||||
scope: scope,
|
||||
icon: favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
|
||||
label: favorite ? scope.context.t.favorite : scope.context.t.unfavorite,
|
||||
isVisible: assetIds.isNotEmpty,
|
||||
);
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id).favorite(isFavorite: false);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).update(ids, isFavorite: const .some(true));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.favorite_action_prompt(count: ids.length));
|
||||
}
|
||||
}
|
||||
|
||||
class UnfavoriteAction extends BaseAction {
|
||||
const UnfavoriteAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.favorite_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.unfavorite;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final owned = AssetFilter(assets).owned(currentUser(ref).id);
|
||||
return owned.favorite(isFavorite: false).isEmpty ? owned : const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, isFavorite: .some(favorite));
|
||||
final message = favorite
|
||||
? context.t.favorite_action_prompt(count: assetIds.length)
|
||||
: context.t.unfavorite_action_prompt(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).update(ids, isFavorite: const .some(false));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.unfavorite_action_prompt(count: ids.length));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -7,41 +8,67 @@ 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
|
||||
IconData get icon => Icons.lock_rounded;
|
||||
|
||||
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);
|
||||
@override
|
||||
String label(context) => context.t.move_to_locked_folder;
|
||||
|
||||
return LockAction._(
|
||||
assetIds: assetIds,
|
||||
lock: lock,
|
||||
scope: scope,
|
||||
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,
|
||||
);
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id).locked(isLocked: false);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).update(ids, visibility: const .some(.locked));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.move_to_lock_folder_action_prompt(count: ids.length));
|
||||
}
|
||||
}
|
||||
|
||||
class UnlockAction extends BaseAction {
|
||||
const UnlockAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.lock_open_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.remove_from_locked_folder;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final owned = AssetFilter(assets).owned(currentUser(ref).id);
|
||||
return owned.locked(isLocked: false).isEmpty ? owned : const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final ActionScope(:ref, :context) = scope;
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
await ref.read(assetServiceProvider).update(assetIds, 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);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
await assetService.update(ids, visibility: const .some(.timeline));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(toastRepositoryProvider)
|
||||
.success(
|
||||
context.t.remove_from_lock_folder_action_prompt(count: ids.length),
|
||||
toast: .new(onUndo: () async => await assetService.update(ids, visibility: const .some(.locked))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
@@ -6,16 +8,22 @@ 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', '');
|
||||
IconData get icon => Icons.open_in_browser;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.open_in_browser;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assets.firstOrNull?.hasRemote ?? false;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
|
||||
final originPath = switch (origin) {
|
||||
.favorite => '/favorites',
|
||||
.trash => '/trash',
|
||||
@@ -23,6 +31,7 @@ class OpenInBrowserAction extends BaseAction {
|
||||
_ => '',
|
||||
};
|
||||
|
||||
final remoteId = assets.first.remoteId;
|
||||
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: .externalApplication);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -9,11 +10,18 @@ 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;
|
||||
IconData get icon => Icons.person_add_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.add_partner;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final authUser = currentUser(ref);
|
||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||
if (selected == null) {
|
||||
return;
|
||||
@@ -24,15 +32,21 @@ 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;
|
||||
IconData get icon => Icons.person_remove_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.remove;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final authUser = currentUser(ref);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -8,26 +9,31 @@ 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;
|
||||
IconData get icon => Icons.remove_circle_outline;
|
||||
|
||||
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: assetIds);
|
||||
@override
|
||||
String label(context) => context.t.remove_from_album;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<String> assetsForAction(Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).map((asset) => asset.remoteId).nonNulls;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final ids = assetsForAction(assets).toList(growable: false);
|
||||
|
||||
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: ids);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.remove_from_album_action_prompt(count: count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -7,23 +8,36 @@ 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));
|
||||
IconData get icon => Icons.history_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.restore;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id).trashed();
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
await assetService.restoreTrash(ids);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(toastRepositoryProvider)
|
||||
.success(
|
||||
context.t.assets_restored_count(count: ids.length),
|
||||
toast: .new(onUndo: () async => await assetService.trash(ids)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -8,25 +9,30 @@ 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;
|
||||
IconData get icon => Icons.image_outlined;
|
||||
|
||||
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetIds.first);
|
||||
@override
|
||||
String label(context) => context.t.set_as_album_cover;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<String> assetsForAction(Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).map((asset) => asset.remoteId).nonNulls;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(assets).singleOrNull != null;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
|
||||
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetsForAction(assets).single);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.album_cover_updated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,25 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/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)));
|
||||
IconData get icon => Icons.account_circle_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.set_as_profile_picture;
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assets.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async =>
|
||||
unawaited(ref.context.pushRoute(ProfilePictureCropRoute(asset: assets.first)));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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/extensions/build_context_extensions.dart';
|
||||
@@ -12,27 +13,26 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
|
||||
class ShareAction extends BaseAction {
|
||||
final List<BaseAsset> assets;
|
||||
|
||||
ShareAction._({required this.assets, required super.scope, super.isVisible})
|
||||
: super(
|
||||
icon: CurrentPlatform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
label: scope.context.t.share,
|
||||
);
|
||||
|
||||
factory ShareAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final shareable = assets.toList(growable: false);
|
||||
return ._(assets: shareable, scope: scope, isVisible: shareable.isNotEmpty);
|
||||
}
|
||||
const ShareAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() => _share(scope.ref.read(appConfigProvider).share.fileType);
|
||||
IconData get icon => CurrentPlatform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded;
|
||||
|
||||
@override
|
||||
Future<void> Function()? get onSecondaryAction => _promptQualityAndShare;
|
||||
String label(context) => context.t.share;
|
||||
|
||||
Future<void> _promptQualityAndShare() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assets.isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
_share(ref, assets, ref.read(appConfigProvider).share.fileType);
|
||||
|
||||
@override
|
||||
Future<void> Function(WidgetRef ref, Iterable<BaseAsset> assets)? get onSecondaryAction => _promptQualityAndShare;
|
||||
|
||||
Future<void> _promptQualityAndShare(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
|
||||
// only show preview option when at least one of the assets is not a video
|
||||
// we cant share previews of videos
|
||||
@@ -54,11 +54,11 @@ class ShareAction extends BaseAction {
|
||||
return;
|
||||
}
|
||||
|
||||
await _share(fileType);
|
||||
await _share(ref, assets, fileType);
|
||||
}
|
||||
|
||||
Future<void> _share(ShareAssetType fileType) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
Future<void> _share(WidgetRef ref, Iterable<BaseAsset> assets, ShareAssetType fileType) async {
|
||||
final context = ref.context;
|
||||
final cancelCompleter = Completer<void>();
|
||||
final progress = ValueNotifier<double?>(null);
|
||||
|
||||
@@ -81,7 +81,7 @@ class ShareAction extends BaseAction {
|
||||
ref
|
||||
.read(assetMediaRepositoryProvider)
|
||||
.shareAssets(
|
||||
assets,
|
||||
assets.toList(growable: false),
|
||||
context,
|
||||
fileType: fileType,
|
||||
cancelCompleter: cancelCompleter,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -9,16 +10,22 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
class ShareLinkAction extends BaseAction {
|
||||
final List<String> remoteIds;
|
||||
|
||||
ShareLinkAction._({required this.remoteIds, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.link_rounded, label: scope.context.t.share_link);
|
||||
|
||||
factory ShareLinkAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final remoteIds = AssetFilter(assets).remote().map((asset) => asset.id).toList(growable: false);
|
||||
return ._(remoteIds: remoteIds, scope: scope, isVisible: remoteIds.isNotEmpty);
|
||||
}
|
||||
const ShareLinkAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() async => unawaited(scope.context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||
IconData get icon => Icons.link_rounded;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.share_link;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<String> assetsForAction(Iterable<BaseAsset> assets) => AssetFilter(assets).remote().map((asset) => asset.id);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async => unawaited(
|
||||
ref.context.pushRoute(SharedLinkEditRoute(assetsList: assetsForAction(assets).toList(growable: false))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,34 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/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';
|
||||
import 'package:immich_mobile/utils/asset_filter.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;
|
||||
IconData get icon => Icons.compare;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.view_similar_photos;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetForAction(Iterable<BaseAsset> assets) => AssetFilter(assets).remote();
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetForAction(assets).firstOrNull != null;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final asset = assetForAction(assets).first;
|
||||
final context = ref.context;
|
||||
|
||||
ref.invalidate(assetViewerProvider);
|
||||
ref.invalidate(paginatedSearchProvider);
|
||||
@@ -26,15 +37,15 @@ class SimilarPhotosAction extends BaseAction {
|
||||
ref.read(searchPreFilterProvider.notifier)
|
||||
..clear()
|
||||
..setFilter(
|
||||
SearchFilter(
|
||||
assetId: assetId,
|
||||
.new(
|
||||
assetId: asset.id,
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
location: .new(),
|
||||
camera: .new(),
|
||||
date: .new(),
|
||||
display: .new(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: .new(),
|
||||
mediaType: .other,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,17 +2,23 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
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))));
|
||||
}
|
||||
IconData get icon => Icons.slideshow;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.slideshow;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async =>
|
||||
unawaited(ref.context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))));
|
||||
}
|
||||
|
||||
@@ -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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -7,50 +8,67 @@ 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();
|
||||
|
||||
const StackAction._({
|
||||
required this.assetIds,
|
||||
required this.stackIds,
|
||||
required this.stack,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
@override
|
||||
IconData get icon => Icons.filter_none_rounded;
|
||||
|
||||
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);
|
||||
@override
|
||||
String label(context) => context.t.stack;
|
||||
|
||||
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,
|
||||
);
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final owned = AssetFilter(assets).owned(currentUser(ref).id);
|
||||
// elementAtOrNull instead of length check to avoid iterating the entire list
|
||||
return owned.elementAtOrNull(1) != null && owned.stacked(isStacked: false).isNotEmpty;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final authUser = currentUser(ref);
|
||||
final ids = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
final message = stack
|
||||
? context.t.stacked_assets_count(count: assetIds.length)
|
||||
: context.t.unstacked_assets_count(count: assetIds.length);
|
||||
ref.read(toastRepositoryProvider).success(message);
|
||||
await ref.read(assetServiceProvider).stack(authUser.id, ids);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.stacked_assets_count(count: ids.length));
|
||||
}
|
||||
}
|
||||
|
||||
class UnstackAction extends BaseAction {
|
||||
const UnstackAction();
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.layers_clear_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.unstack;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) {
|
||||
final owned = AssetFilter(assets).owned(currentUser(ref).id);
|
||||
return owned.stacked(isStacked: false).isEmpty ? owned : const [];
|
||||
}
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final stacked = assetsForAction(ref, assets).toList(growable: false);
|
||||
final stackIds = stacked.map((asset) => asset.stackId).nonNulls.toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).unstack(stackIds);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(toastRepositoryProvider).success(context.t.unstacked_assets_count(count: stackIds.length));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
@@ -9,29 +10,43 @@ import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_mobile/widgets/common/tag_picker.dart';
|
||||
|
||||
class TagAction extends BaseAction {
|
||||
final List<String> assetIds;
|
||||
|
||||
TagAction._({required this.assetIds, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.sell_outlined, label: scope.context.t.control_bottom_app_bar_add_tags);
|
||||
|
||||
factory TagAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
|
||||
final assetIds = AssetFilter(assets).owned(scope.authUser.id).map((asset) => asset.id).toList(growable: false);
|
||||
return ._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
|
||||
}
|
||||
const TagAction();
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
final results = await showTagPickerModal(context: scope.context);
|
||||
IconData get icon => Icons.sell_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.control_bottom_app_bar_add_tags;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<RemoteAsset> assetsForAction(WidgetRef ref, Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).owned(currentUser(ref).id);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(ref, assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final assetIds = assetsForAction(ref, assets).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
final results = await showTagPickerModal(context: context);
|
||||
if (results == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tagAssets(selected: results.$1, created: results.$2);
|
||||
await applyTags(ref, assetIds, selected: results.$1, created: results.$2);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> tagAssets({required Set<String> selected, required Set<String> created}) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
Future<void> applyTags(
|
||||
WidgetRef ref,
|
||||
List<String> assetIds, {
|
||||
required Set<String> selected,
|
||||
required Set<String> created,
|
||||
}) async {
|
||||
final context = ref.context;
|
||||
|
||||
final tagService = ref.read(tagServiceProvider);
|
||||
final tagIds = {...selected};
|
||||
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
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/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();
|
||||
IconData get icon => action.icon;
|
||||
|
||||
@override
|
||||
String label(BuildContext context) => action.label(context);
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => action.isVisible(ref, assets);
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
await action.onAction(ref, assets);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> Function()? get onSecondaryAction {
|
||||
final inner = action.onSecondaryAction;
|
||||
if (inner == null) {
|
||||
Future<void> Function(WidgetRef ref, Iterable<BaseAsset> assets)? get onSecondaryAction {
|
||||
final onSecondaryAction = action.onSecondaryAction;
|
||||
if (onSecondaryAction == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return () async {
|
||||
await inner();
|
||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||
return (ref, assets) async {
|
||||
await onSecondaryAction(ref, assets);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
@@ -12,30 +13,33 @@ import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class UploadAction extends BaseAction {
|
||||
final List<LocalAsset> assets;
|
||||
final ActionSource source;
|
||||
|
||||
final bool showProgress;
|
||||
|
||||
UploadAction._({required this.assets, required this.showProgress, required super.scope, super.isVisible})
|
||||
: super(icon: Icons.backup_outlined, label: scope.context.t.upload);
|
||||
|
||||
factory UploadAction({required Iterable<BaseAsset> assets, required ActionScope scope, bool showProgress = false}) {
|
||||
final local = AssetFilter(assets).backedUp(isBackedUp: false).local().toList(growable: false);
|
||||
return ._(assets: local, scope: scope, showProgress: showProgress, isVisible: local.isNotEmpty);
|
||||
}
|
||||
const UploadAction({required this.source});
|
||||
|
||||
@override
|
||||
Future<void> onAction() async {
|
||||
if (assets.isEmpty) {
|
||||
IconData get icon => Icons.backup_outlined;
|
||||
|
||||
@override
|
||||
String label(context) => context.t.upload;
|
||||
|
||||
@visibleForTesting
|
||||
Iterable<LocalAsset> assetsForAction(Iterable<BaseAsset> assets) =>
|
||||
AssetFilter(assets).backedUp(isBackedUp: false).local();
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => assetsForAction(assets).isNotEmpty;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final localAssets = assetsForAction(assets).toList(growable: false);
|
||||
|
||||
if (source != ActionSource.viewer) {
|
||||
await upload(ref, localAssets);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showProgress) {
|
||||
await upload();
|
||||
return;
|
||||
}
|
||||
|
||||
final context = scope.context;
|
||||
var isDialogOpen = true;
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
@@ -45,7 +49,7 @@ class UploadAction extends BaseAction {
|
||||
).whenComplete(() => isDialogOpen = false),
|
||||
);
|
||||
|
||||
await upload();
|
||||
await upload(ref, localAssets);
|
||||
|
||||
if (isDialogOpen && context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
@@ -53,8 +57,8 @@ class UploadAction extends BaseAction {
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> upload() async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
Future<void> upload(WidgetRef ref, List<LocalAsset> assets) async {
|
||||
final context = ref.context;
|
||||
final progress = ref.read(assetUploadProgressProvider.notifier);
|
||||
final cancelToken = Completer<void>();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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';
|
||||
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/lock.action.dart';
|
||||
@@ -47,7 +45,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 +64,10 @@ 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: UnarchiveAction()),
|
||||
const ActionMenuItemWidget(source: .viewer, action: LockAction()),
|
||||
const ActionMenuItemWidget(source: .viewer, action: UnlockAction()),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -115,7 +110,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.viewer, album);
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(.viewer, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
|
||||
@@ -3,13 +3,11 @@ 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';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
@@ -30,7 +28,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));
|
||||
const editDateTime = EditDateTimeAction();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -38,7 +36,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.isVisible(ref, [asset]) ? () => editDateTime.onAction(ref, [asset]) : null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
],
|
||||
@@ -91,7 +89,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
return;
|
||||
}
|
||||
|
||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
|
||||
final editAction = await ref.read(actionProvider.notifier).updateDescription(.viewer, newDescription);
|
||||
|
||||
if (!editAction.success) {
|
||||
_controller.text = previousDescription ?? '';
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||
@@ -64,7 +63,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final editLocation = EditLocationAction(assets: [asset], scope: ActionScope.from(context, ref));
|
||||
const editLocation = EditLocationAction();
|
||||
final editLocationAction = editLocation.isVisible(ref, [asset]) ? () => editLocation.onAction(ref, [asset]) : null;
|
||||
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: editLocationAction,
|
||||
),
|
||||
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: editLocationAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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';
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_but
|
||||
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';
|
||||
|
||||
@@ -30,32 +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;
|
||||
Widget? column(BaseAction action) =>
|
||||
action.isVisible(ref, [asset]) ? ActionColumnButtonWidget(source: .viewer, action: action) : null;
|
||||
|
||||
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 upload = UploadAction(assets: assets, scope: scope, showProgress: true);
|
||||
final actions = <Widget>[
|
||||
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (editImage.isVisible) ActionColumnButtonWidget(action: editImage),
|
||||
|
||||
?column(const RestoreAction()),
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (upload.isVisible) ActionColumnButtonWidget(action: upload),
|
||||
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 TrashAction()),
|
||||
?column(const DeletePermanentlyAction()),
|
||||
?column(const DeleteLocalAction()),
|
||||
].toList();
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
},
|
||||
),
|
||||
|
||||
ActionIconButtonWidget(
|
||||
action: FavoriteAction(assets: assetForAction, scope: scope),
|
||||
),
|
||||
const ActionIconButtonWidget(source: .viewer, action: FavoriteAction()),
|
||||
const ActionIconButtonWidget(source: .viewer, action: UnfavoriteAction()),
|
||||
|
||||
ViewerKebabMenu(originalTheme: originalTheme),
|
||||
];
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
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/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/stack.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 ArchiveBottomSheet extends ConsumerStatefulWidget {
|
||||
@@ -39,10 +42,8 @@ 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);
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
@@ -65,35 +66,28 @@ 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 assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
actions: const [
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
|
||||
TimelineSheetActionWidget(action: FavoriteAction()),
|
||||
TimelineSheetActionWidget(action: UnfavoriteAction()),
|
||||
TimelineSheetActionWidget(action: ArchiveAction()),
|
||||
TimelineSheetActionWidget(action: UnarchiveAction()),
|
||||
TimelineSheetActionWidget(action: TrashAction()),
|
||||
TimelineSheetActionWidget(action: DeletePermanentlyAction()),
|
||||
TimelineSheetActionWidget(action: DeleteLocalAction()),
|
||||
TimelineSheetActionWidget(action: StackAction()),
|
||||
TimelineSheetActionWidget(action: UnstackAction()),
|
||||
TimelineSheetActionWidget(action: LockAction()),
|
||||
TimelineSheetActionWidget(action: UnlockAction()),
|
||||
TimelineSheetActionWidget(action: EditDateTimeAction()),
|
||||
TimelineSheetActionWidget(action: EditLocationAction()),
|
||||
TimelineSheetActionWidget(action: DownloadAction()),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -3,12 +3,17 @@ import 'package:hooks_riverpod/hooks_riverpod.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/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/stack.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/album.provider.dart';
|
||||
@@ -55,34 +60,28 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
initialChildSize: 0.4,
|
||||
maxChildSize: 0.7,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
...[
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
],
|
||||
actions: const [
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
|
||||
TimelineSheetActionWidget(action: FavoriteAction()),
|
||||
TimelineSheetActionWidget(action: UnfavoriteAction()),
|
||||
TimelineSheetActionWidget(action: ArchiveAction()),
|
||||
TimelineSheetActionWidget(action: UnarchiveAction()),
|
||||
TimelineSheetActionWidget(action: TrashAction()),
|
||||
TimelineSheetActionWidget(action: DeletePermanentlyAction()),
|
||||
TimelineSheetActionWidget(action: DeleteLocalAction()),
|
||||
TimelineSheetActionWidget(action: CleanupLocalAction()),
|
||||
TimelineSheetActionWidget(action: StackAction()),
|
||||
TimelineSheetActionWidget(action: UnstackAction()),
|
||||
TimelineSheetActionWidget(action: LockAction()),
|
||||
TimelineSheetActionWidget(action: UnlockAction()),
|
||||
TimelineSheetActionWidget(action: EditDateTimeAction()),
|
||||
TimelineSheetActionWidget(action: EditLocationAction()),
|
||||
TimelineSheetActionWidget(action: DownloadAction()),
|
||||
],
|
||||
slivers: multiselect.hasRemote
|
||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
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/timeline.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/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 GeneralBottomSheet extends ConsumerStatefulWidget {
|
||||
@@ -39,10 +45,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
||||
|
||||
@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);
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
@@ -64,39 +68,33 @@ 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 assets = multiselect.selectedAssets.toList(growable: false);
|
||||
final actions = AssetActions.from(scope, assets);
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: widget.minChildSize ?? 0.15,
|
||||
minChildSize: widget.minChildSize,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
...[
|
||||
actions.debug,
|
||||
actions.favorite,
|
||||
actions.archive,
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
actions.stack,
|
||||
actions.lock,
|
||||
actions.editDateTime,
|
||||
actions.editLocation,
|
||||
actions.download,
|
||||
actions.tag,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
],
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.upload)),
|
||||
actions: const [
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
|
||||
TimelineSheetActionWidget(action: AssetDebugAction()),
|
||||
TimelineSheetActionWidget(action: FavoriteAction()),
|
||||
TimelineSheetActionWidget(action: UnfavoriteAction()),
|
||||
TimelineSheetActionWidget(action: ArchiveAction()),
|
||||
TimelineSheetActionWidget(action: UnarchiveAction()),
|
||||
TimelineSheetActionWidget(action: TrashAction()),
|
||||
TimelineSheetActionWidget(action: DeletePermanentlyAction()),
|
||||
TimelineSheetActionWidget(action: DeleteLocalAction()),
|
||||
TimelineSheetActionWidget(action: CleanupLocalAction()),
|
||||
TimelineSheetActionWidget(action: StackAction()),
|
||||
TimelineSheetActionWidget(action: UnstackAction()),
|
||||
TimelineSheetActionWidget(action: LockAction()),
|
||||
TimelineSheetActionWidget(action: UnlockAction()),
|
||||
TimelineSheetActionWidget(action: EditDateTimeAction()),
|
||||
TimelineSheetActionWidget(action: EditLocationAction()),
|
||||
TimelineSheetActionWidget(action: DownloadAction()),
|
||||
TimelineSheetActionWidget(action: TagAction()),
|
||||
TimelineSheetActionWidget(action: UploadAction(source: .timeline)),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
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/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 {
|
||||
@@ -39,7 +36,7 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
@@ -62,24 +59,18 @@ 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: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
...[
|
||||
actions.delete,
|
||||
actions.cleanup,
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.upload)),
|
||||
actions: const [
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
TimelineSheetActionWidget(action: TrashAction()),
|
||||
TimelineSheetActionWidget(action: DeletePermanentlyAction()),
|
||||
TimelineSheetActionWidget(action: DeleteLocalAction()),
|
||||
TimelineSheetActionWidget(action: CleanupLocalAction()),
|
||||
TimelineSheetActionWidget(action: UploadAction(source: .timeline)),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
import 'package:flutter/material.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/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/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(
|
||||
return const BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.download)),
|
||||
...[
|
||||
actions.delete,
|
||||
LockAction(assets: assets, scope: scope),
|
||||
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
TimelineSheetActionWidget(action: DownloadAction()),
|
||||
TimelineSheetActionWidget(action: TrashAction()),
|
||||
TimelineSheetActionWidget(action: DeletePermanentlyAction()),
|
||||
TimelineSheetActionWidget(action: DeleteLocalAction()),
|
||||
TimelineSheetActionWidget(action: LockAction()),
|
||||
TimelineSheetActionWidget(action: UnlockAction()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import 'package:flutter/material.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/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';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class PartnerDetailBottomSheet extends ConsumerWidget {
|
||||
const PartnerDetailBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scope = ActionScope.from(context, ref);
|
||||
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
|
||||
|
||||
return BaseBottomSheet(
|
||||
return const BaseBottomSheet(
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: DownloadAction(assets: assets, scope: scope),
|
||||
),
|
||||
),
|
||||
ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
TimelineSheetActionWidget(action: DownloadAction()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/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/timeline.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/stack.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';
|
||||
@@ -47,7 +51,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
@@ -74,10 +78,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,
|
||||
@@ -85,40 +85,28 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
if (multiselect.hasRemote) ...[
|
||||
ActionColumnButtonWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
|
||||
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))),
|
||||
],
|
||||
ActionColumnButtonWidget(action: TimelineAction(action: actions.download)),
|
||||
const ActionColumnButtonWidget(source: .timeline, action: ShareAction()),
|
||||
const ActionColumnButtonWidget(source: .timeline, action: ShareLinkAction()),
|
||||
if (ownsAlbum) ...const <TimelineSheetActionWidget>[
|
||||
.new(action: FavoriteAction()),
|
||||
.new(action: UnfavoriteAction()),
|
||||
.new(action: ArchiveAction()),
|
||||
.new(action: UnarchiveAction()),
|
||||
.new(action: TrashAction()),
|
||||
.new(action: DeletePermanentlyAction()),
|
||||
.new(action: DeleteLocalAction()),
|
||||
.new(action: StackAction()),
|
||||
.new(action: UnstackAction()),
|
||||
.new(action: LockAction()),
|
||||
.new(action: UnlockAction()),
|
||||
.new(action: EditDateTimeAction()),
|
||||
.new(action: EditLocationAction()),
|
||||
],
|
||||
if (ownsAlbum)
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: RemoveFromAlbumAction(assets: assets, albumId: widget.album.id, scope: scope),
|
||||
),
|
||||
),
|
||||
if (ownsAlbum)
|
||||
ActionColumnButtonWidget(
|
||||
action: TimelineAction(
|
||||
action: SetAlbumCoverAction(assets: assets, albumId: widget.album.id, scope: scope),
|
||||
),
|
||||
),
|
||||
const TimelineSheetActionWidget(action: DownloadAction()),
|
||||
const TimelineSheetActionWidget(action: CleanupLocalAction()),
|
||||
if (ownsAlbum) TimelineSheetActionWidget(action: RemoveFromAlbumAction(albumId: widget.album.id)),
|
||||
if (ownsAlbum && multiselect.selectedAssets.length == 1)
|
||||
TimelineSheetActionWidget(action: SetAlbumCoverAction(albumId: widget.album.id)),
|
||||
],
|
||||
slivers: ownsAlbum
|
||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
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(
|
||||
color: context.themeData.canvasColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: SafeArea(
|
||||
child: const SafeArea(
|
||||
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: <TimelineSheetActionWidget>[
|
||||
.new(action: TrashAction()),
|
||||
.new(action: DeletePermanentlyAction()),
|
||||
.new(action: DeleteLocalAction()),
|
||||
.new(action: RestoreAction()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,14 +13,23 @@ class ToastRepository {
|
||||
const ToastRepository();
|
||||
|
||||
FutureOr<void> success(String message, {ToastOption? toast}) {
|
||||
snackbar.success(message, duration: toast?.timeout);
|
||||
snackbar.success(message, duration: toast?.timeout, action: toast?.action);
|
||||
}
|
||||
|
||||
FutureOr<void> info(String message, {ToastOption? toast}) {
|
||||
snackbar.info(message, duration: toast?.timeout);
|
||||
snackbar.info(message, duration: toast?.timeout, action: toast?.action);
|
||||
}
|
||||
|
||||
FutureOr<void> error(String message, {ToastOption? toast}) {
|
||||
snackbar.error(message, duration: toast?.timeout);
|
||||
snackbar.error(message, duration: toast?.timeout, action: toast?.action);
|
||||
}
|
||||
}
|
||||
|
||||
extension on ToastOption {
|
||||
SnackbarAction? get action {
|
||||
if (onUndo == null) {
|
||||
return null;
|
||||
}
|
||||
return SnackbarAction(onPressed: onUndo!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
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';
|
||||
@@ -67,6 +65,7 @@ enum ActionButtonType {
|
||||
share,
|
||||
shareLink,
|
||||
cast,
|
||||
unCast,
|
||||
setAlbumCover,
|
||||
similarPhotos,
|
||||
setProfilePicture,
|
||||
@@ -162,74 +161,64 @@ enum ActionButtonType {
|
||||
context.timelineOrigin != TimelineOrigin.archive &&
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
ActionButtonType.cast => context.asset.hasRemote,
|
||||
ActionButtonType.unCast => context.isCasting,
|
||||
ActionButtonType.slideshow => true,
|
||||
};
|
||||
}
|
||||
|
||||
Widget buildButton(
|
||||
ActionButtonContext context,
|
||||
BuildContext buildContext,
|
||||
|
||||
WidgetRef ref, [
|
||||
BuildContext buildContext, [
|
||||
bool iconOnly = false,
|
||||
bool menuItem = false,
|
||||
]) {
|
||||
final assets = [context.asset];
|
||||
final scope = ActionScope.from(buildContext, ref);
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => ActionMenuItemWidget(
|
||||
action: AssetDebugAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.share => ActionMenuItemWidget(
|
||||
action: ShareAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.shareLink => ActionMenuItemWidget(
|
||||
action: ShareLinkAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
|
||||
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
|
||||
action: ArchiveAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.download => ActionMenuItemWidget(
|
||||
action: DownloadAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.restoreTrash => ActionMenuItemWidget(
|
||||
action: RestoreAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.delete => ActionMenuItemWidget(
|
||||
action: DeleteAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
|
||||
action: LockAction(assets: assets, scope: scope),
|
||||
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 => ActionMenuItemWidget(source: context.source, action: const ArchiveAction()),
|
||||
ActionButtonType.restoreTrash => ActionMenuItemWidget(source: context.source, action: const RestoreAction()),
|
||||
ActionButtonType.delete => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ActionMenuItemWidget(source: context.source, action: const TrashAction()),
|
||||
ActionMenuItemWidget(source: context.source, action: const DeletePermanentlyAction()),
|
||||
ActionMenuItemWidget(source: context.source, action: const DeleteLocalAction()),
|
||||
],
|
||||
),
|
||||
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(source: context.source, action: const LockAction()),
|
||||
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
|
||||
action: LockAction(assets: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.deleteLocal => ActionMenuItemWidget(
|
||||
action: CleanupLocalAction(assets: assets, scope: scope),
|
||||
source: context.source,
|
||||
action: const UnlockAction(),
|
||||
),
|
||||
ActionButtonType.deleteLocal => ActionMenuItemWidget(source: context.source, action: const CleanupLocalAction()),
|
||||
ActionButtonType.upload => ActionMenuItemWidget(
|
||||
action: UploadAction(assets: assets, scope: scope, showProgress: context.source == ActionSource.viewer),
|
||||
source: context.source,
|
||||
action: UploadAction(source: context.source),
|
||||
),
|
||||
ActionButtonType.removeFromAlbum => ActionMenuItemWidget(
|
||||
action: RemoveFromAlbumAction(assets: assets, albumId: context.currentAlbum!.id, scope: scope),
|
||||
source: context.source,
|
||||
action: RemoveFromAlbumAction(albumId: context.currentAlbum!.id),
|
||||
),
|
||||
ActionButtonType.setAlbumCover => ActionMenuItemWidget(
|
||||
action: SetAlbumCoverAction(assets: assets, 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: assets, scope: scope),
|
||||
),
|
||||
ActionButtonType.unstack => ActionMenuItemWidget(source: context.source, action: const UnstackAction()),
|
||||
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(),
|
||||
@@ -247,7 +236,10 @@ 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.unCast => ActionMenuItemWidget(source: context.source, action: const UnCastAction()),
|
||||
ActionButtonType.download => ActionMenuItemWidget(source: context.source, action: const DownloadAction()),
|
||||
ActionButtonType.unarchive => ActionMenuItemWidget(source: context.source, action: const UnarchiveAction()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,14 +279,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();
|
||||
@@ -310,7 +302,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:immich_ui/src/internal.dart';
|
||||
|
||||
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
class SnackbarAction {
|
||||
final String? label;
|
||||
final FutureOr<void> Function() onPressed;
|
||||
|
||||
const SnackbarAction({this.label, required this.onPressed});
|
||||
}
|
||||
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
@@ -10,6 +20,7 @@ class SnackbarManager {
|
||||
String message,
|
||||
SnackbarType type, {
|
||||
Duration? duration,
|
||||
SnackbarAction? action,
|
||||
}) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
@@ -19,10 +30,10 @@ class SnackbarManager {
|
||||
|
||||
duration ??= const .new(seconds: 4);
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type, duration));
|
||||
return messenger.showSnackBar(_build(context, message, type, duration, action));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration, SnackbarAction? action) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
@@ -31,11 +42,21 @@ class SnackbarManager {
|
||||
.error => (Icons.warning_rounded, colors.error, colors.onError),
|
||||
};
|
||||
|
||||
SnackBarAction? snackAction;
|
||||
if (action != null) {
|
||||
snackAction = .new(
|
||||
label: action.label ?? context.translations.undo,
|
||||
onPressed: action.onPressed,
|
||||
textColor: foreground,
|
||||
);
|
||||
}
|
||||
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: duration,
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
persist: false,
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(icon, color: foreground, size: ImmichIconSize.sm),
|
||||
@@ -50,17 +71,27 @@ class SnackbarManager {
|
||||
),
|
||||
],
|
||||
),
|
||||
action: snackAction,
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
|
||||
show(message, .info, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(
|
||||
String message, {
|
||||
Duration? duration,
|
||||
SnackbarAction? action,
|
||||
}) => show(message, .info, duration: duration, action: action);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
|
||||
show(message, .success, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(
|
||||
String message, {
|
||||
Duration? duration,
|
||||
SnackbarAction? action,
|
||||
}) => show(message, .success, duration: duration, action: action);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
|
||||
show(message, .error, duration: duration);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(
|
||||
String message, {
|
||||
Duration? duration,
|
||||
SnackbarAction? action,
|
||||
}) => show(message, .error, duration: duration, action: action);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImmichTranslations {
|
||||
late String submit;
|
||||
late String password;
|
||||
final String submit;
|
||||
final String password;
|
||||
final String undo;
|
||||
|
||||
ImmichTranslations({String? submit, String? password}) {
|
||||
this.submit = submit ?? 'Submit';
|
||||
this.password = password ?? 'Password';
|
||||
}
|
||||
const ImmichTranslations({String? submit, String? password, String? undo})
|
||||
: submit = submit ?? 'Submit',
|
||||
password = password ?? 'Password',
|
||||
undo = undo ?? 'Undo';
|
||||
}
|
||||
|
||||
class ImmichTranslationProvider extends InheritedWidget {
|
||||
final ImmichTranslations? translations;
|
||||
|
||||
const ImmichTranslationProvider({
|
||||
super.key,
|
||||
this.translations,
|
||||
required super.child,
|
||||
});
|
||||
const ImmichTranslationProvider({super.key, this.translations, required super.child});
|
||||
|
||||
static ImmichTranslations of(BuildContext context) {
|
||||
final provider = context.dependOnInheritedWidgetOfExactType<ImmichTranslationProvider>();
|
||||
return provider?.translations ?? ImmichTranslations();
|
||||
return provider?.translations ?? const ImmichTranslations();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
import '../presentation_context.dart';
|
||||
@@ -8,37 +11,26 @@ import '../presentation_context.dart';
|
||||
class _RecordingAction extends BaseAction {
|
||||
final void Function() onTap;
|
||||
final void Function()? onLong;
|
||||
final bool visible;
|
||||
|
||||
const _RecordingAction._({
|
||||
required this.onTap,
|
||||
required this.onLong,
|
||||
required super.scope,
|
||||
required super.icon,
|
||||
required super.label,
|
||||
super.isVisible,
|
||||
});
|
||||
|
||||
factory _RecordingAction(
|
||||
ActionScope scope, {
|
||||
required void Function() onTap,
|
||||
void Function()? onLong,
|
||||
bool isVisible = true,
|
||||
}) => _RecordingAction._(
|
||||
scope: scope,
|
||||
onTap: onTap,
|
||||
onLong: onLong,
|
||||
icon: Icons.bug_report_outlined,
|
||||
label: 'test',
|
||||
isVisible: isVisible,
|
||||
);
|
||||
const _RecordingAction({required this.onTap, this.onLong, this.visible = true});
|
||||
|
||||
@override
|
||||
Future<void> onAction() async => onTap();
|
||||
IconData get icon => Icons.bug_report_outlined;
|
||||
|
||||
@override
|
||||
Future<void> Function()? get onSecondaryAction {
|
||||
final callback = onLong;
|
||||
return callback == null ? null : () async => callback();
|
||||
String label(_) => 'test';
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => visible;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async => onTap();
|
||||
|
||||
@override
|
||||
Future<void> Function(WidgetRef ref, Iterable<BaseAsset> assets)? get onSecondaryAction {
|
||||
final onLong = this.onLong;
|
||||
return onLong == null ? null : (ref, assets) async => onLong();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +45,18 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
group('ActionIconButtonWidget', () {
|
||||
group('Action Widgets', () {
|
||||
testWidgets('renders nothing when the action is not visible', (tester) async {
|
||||
await tester.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}, isVisible: false));
|
||||
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.pumpActionButton(context, (scope) => _RecordingAction(scope, onTap: () {}));
|
||||
await tester.pumpTestWidget(context, ActionIconButtonWidget(action: _RecordingAction(onTap: () {})));
|
||||
|
||||
expect(tester.widget<ImmichIconButton>(find.byType(ImmichIconButton)).onLongPress, isNull);
|
||||
});
|
||||
@@ -69,9 +64,11 @@ void main() {
|
||||
testWidgets('tap runs the primary action', (tester) async {
|
||||
var taps = 0;
|
||||
var longPresses = 0;
|
||||
await tester.pumpActionButton(
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
|
||||
ActionIconButtonWidget(
|
||||
action: _RecordingAction(onTap: () => taps++, onLong: () => longPresses++),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(ImmichIconButton));
|
||||
@@ -84,9 +81,11 @@ void main() {
|
||||
testWidgets('long press runs the secondary action, not the primary', (tester) async {
|
||||
var taps = 0;
|
||||
var longPresses = 0;
|
||||
await tester.pumpActionButton(
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
(scope) => _RecordingAction(scope, onTap: () => taps++, onLong: () => longPresses++),
|
||||
ActionIconButtonWidget(
|
||||
action: _RecordingAction(onTap: () => taps++, onLong: () => longPresses++),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(find.byType(ImmichIconButton));
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.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/archive.action.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
@@ -23,87 +22,115 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
RemoteAsset owned({AssetVisibility visibility = AssetVisibility.timeline}) =>
|
||||
RemoteAsset owned({AssetVisibility visibility = .timeline}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
|
||||
|
||||
group('ArchiveAction', () {
|
||||
const action = ArchiveAction();
|
||||
|
||||
testWidgets('archives the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
|
||||
final resolved = await tester.runAction(context, action, assets: [asset]);
|
||||
|
||||
expect(action.icon, Icons.archive_outlined);
|
||||
expect(action.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);
|
||||
|
||||
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);
|
||||
|
||||
verify(() => assetService.update([mine.id], visibility: const Some(AssetVisibility.timeline))).called(1);
|
||||
expect(resolved!.icon, Icons.archive_outlined);
|
||||
expect(resolved.label, StaticTranslations.instance.archive);
|
||||
verify(() => assetService.update([asset.id], visibility: const .some(.archive))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||
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)),
|
||||
).called(1);
|
||||
verify(() => assetService.update([first.id, second.id], visibility: const .some(.archive))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('archives only the owned assets not already archived', (tester) async {
|
||||
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);
|
||||
verify(() => assetService.update([stale.id], visibility: const .some(.archive))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by others', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.update([mine.id], visibility: const .some(.archive))).called(1);
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
group('UnarchiveAction', () {
|
||||
const action = UnarchiveAction();
|
||||
|
||||
testWidgets('unarchive the owned assets when every one is archived', (tester) async {
|
||||
final asset = owned(visibility: .archive);
|
||||
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(.timeline))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by others', (tester) async {
|
||||
final mine = owned(visibility: .archive);
|
||||
final theirs = RemoteAssetFactory.create(visibility: .archive);
|
||||
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.update([mine.id], visibility: const .some(.timeline))).called(1);
|
||||
});
|
||||
|
||||
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;
|
||||
expect(message, StaticTranslations.instance.unarchive_action_prompt(count: 2));
|
||||
});
|
||||
});
|
||||
|
||||
group('toggle visibility', () {
|
||||
testWidgets('a mixed selection shows Archive but not Unarchive', (tester) async {
|
||||
final assets = [owned(), owned(visibility: .archive)];
|
||||
|
||||
final archive = await tester.resolveAction(context, const ArchiveAction(), assets: assets);
|
||||
final unarchive = await tester.resolveAction(context, const UnarchiveAction(), assets: assets);
|
||||
|
||||
expect(archive, isNotNull);
|
||||
expect(unarchive, isNull);
|
||||
});
|
||||
|
||||
testWidgets('an all-archived selection shows Unarchive but not Archive', (tester) async {
|
||||
final assets = [owned(visibility: .archive), owned(visibility: .archive)];
|
||||
|
||||
final archive = await tester.resolveAction(context, const ArchiveAction(), assets: assets);
|
||||
final unarchive = await tester.resolveAction(context, const UnarchiveAction(), assets: assets);
|
||||
|
||||
expect(archive, isNull);
|
||||
expect(unarchive, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,173 +49,169 @@ void main() {
|
||||
Future<void> respondToDialog(WidgetTester tester, {required bool confirm}) async {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(ConfirmDialog), findsOneWidget);
|
||||
await tester.tap(find.byType(TextButton).at(confirm ? 1 : 0)); // [cancel, ok]
|
||||
await tester.tap(find.byType(TextButton).at(confirm ? 1 : 0));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
group('DeleteAction', () {
|
||||
group('trash', () {
|
||||
testWidgets('trashes a remote-only owned asset', (tester) async {
|
||||
final asset = owned();
|
||||
group('TrashAction', () {
|
||||
const action = TrashAction();
|
||||
|
||||
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets('trashes a remote-only owned asset', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||
});
|
||||
await tester.runAction(context, action, assets: [asset]);
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [mine, theirs], scope: scope));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => assetService.trash([mine.id])).called(1);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
});
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||
});
|
||||
|
||||
group('permanent', () {
|
||||
testWidgets('permanently deletes when the trash feature is disabled', (tester) async {
|
||||
final asset = owned();
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
(scope) => DeleteAction(assets: [asset], scope: scope),
|
||||
overrides: disableTrash(),
|
||||
);
|
||||
await respondToDialog(tester, confirm: true);
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
});
|
||||
|
||||
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 respondToDialog(tester, confirm: true);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
});
|
||||
|
||||
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 respondToDialog(tester, confirm: true);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
});
|
||||
|
||||
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 respondToDialog(tester, confirm: true);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
});
|
||||
|
||||
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 respondToDialog(tester, confirm: false);
|
||||
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||
});
|
||||
verify(() => assetService.trash([mine.id])).called(1);
|
||||
});
|
||||
|
||||
group('local only', () {
|
||||
testWidgets('removes the device copy with no remote call', (tester) async {
|
||||
final asset = LocalAssetFactory.create();
|
||||
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([asset.id])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
});
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
});
|
||||
|
||||
group('prompt handling', () {
|
||||
testWidgets('permanent delete shows a single app dialog', (tester) async {
|
||||
final asset = owned(localId: 'local');
|
||||
testWidgets('is hidden when the assets are already trashed', (tester) async {
|
||||
final resolved = await tester.resolveAction(context, action, assets: [owned(deletedAt: .new(2024))]);
|
||||
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
(scope) => DeleteAction(assets: [asset], scope: scope),
|
||||
overrides: disableTrash(),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolved, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
expect(find.text(StaticTranslations.instance.delete_dialog_title), findsOneWidget);
|
||||
await tester.tap(find.byType(TextButton).at(1));
|
||||
await tester.pumpAndSettle();
|
||||
group('DeletePermanentlyAction', () {
|
||||
const action = DeletePermanentlyAction();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsNothing);
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
});
|
||||
testWidgets('permanently deletes when the trash feature is disabled', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
testWidgets('local only delete on Android with MANAGE_MEDIA shows the prompt', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
final asset = LocalAssetFactory.create();
|
||||
await tester.runAction(context, action, assets: [asset], overrides: disableTrash());
|
||||
await respondToDialog(tester, confirm: true);
|
||||
|
||||
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
});
|
||||
|
||||
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsOneWidget);
|
||||
await tester.tap(find.byType(TextButton).at(1)); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async {
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
await tester.runAction(context, action, assets: [asset], overrides: disableTrash());
|
||||
await respondToDialog(tester, confirm: true);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('permanently deletes already trashed assets even with trash enabled', (tester) async {
|
||||
final asset = owned(deletedAt: .new(2024));
|
||||
|
||||
await tester.runAction(context, action, assets: [asset]);
|
||||
await respondToDialog(tester, confirm: true);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
});
|
||||
|
||||
testWidgets('permanently deletes locked folder assets even with trash enabled', (tester) async {
|
||||
final asset = owned(visibility: .locked, localId: 'local');
|
||||
|
||||
await tester.runAction(context, action, assets: [asset]);
|
||||
await respondToDialog(tester, confirm: true);
|
||||
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('does nothing when the confirmation is cancelled', (tester) async {
|
||||
final asset = owned(visibility: .locked, localId: 'local');
|
||||
|
||||
await tester.runAction(context, action, assets: [asset]);
|
||||
await respondToDialog(tester, confirm: false);
|
||||
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||
});
|
||||
|
||||
testWidgets('shows a single app dialog', (tester) async {
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await tester.runAction(context, action, assets: [asset], overrides: disableTrash());
|
||||
await tester.pump(const .new(milliseconds: 300));
|
||||
|
||||
expect(find.text(StaticTranslations.instance.delete_dialog_title), findsOneWidget);
|
||||
await tester.tap(find.byType(TextButton).at(1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsNothing);
|
||||
verify(() => assetService.delete([asset.id])).called(1);
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeleteLocalAction', () {
|
||||
const action = DeleteLocalAction();
|
||||
|
||||
testWidgets('removes the device copy with no remote call', (tester) async {
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
await tester.runAction(context, action, assets: [asset]);
|
||||
|
||||
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
});
|
||||
|
||||
testWidgets('is hidden when the selection has an owned remote asset', (tester) async {
|
||||
final resolved = await tester.resolveAction(context, action, assets: [owned(localId: 'local')]);
|
||||
|
||||
expect(resolved, isNull);
|
||||
});
|
||||
|
||||
testWidgets('on Android with MANAGE_MEDIA shows the prompt', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = .android;
|
||||
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
await tester.runAction(context, action, assets: [asset]);
|
||||
await tester.pump(const .new(milliseconds: 300));
|
||||
|
||||
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsOneWidget);
|
||||
await tester.tap(find.byType(TextButton).at(1)); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../repository.mocks.dart';
|
||||
@@ -22,31 +21,28 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
const action = DownloadAction();
|
||||
|
||||
group('DownloadAction', () {
|
||||
testWidgets('visible when there is a remote asset to download', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => DownloadAction(assets: [RemoteAssetFactory.create()], scope: scope),
|
||||
);
|
||||
final resolved = await tester.resolveAction(context, action, assets: [RemoteAssetFactory.create()]);
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.icon, Icons.download);
|
||||
expect(action.label, StaticTranslations.instance.download);
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
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 action = await tester.pumpActionButton(context, (scope) => DownloadAction(assets: const [], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: const []);
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
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.pumpTestAction(context, (scope) => DownloadAction(assets: [target], scope: scope));
|
||||
await tester.runAction(context, action, assets: [target]);
|
||||
|
||||
verify(() => downloadRepo.downloadAllAssets(any(that: contains(target)))).called(1);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_asset.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_location.action.dart';
|
||||
@@ -13,7 +12,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 +42,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: .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 +118,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 +139,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 +153,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await action.save(const LatLng(1, 2));
|
||||
await action.save(capturedRef, [asset.id], const LatLng(1, 2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final location =
|
||||
@@ -206,36 +170,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 +192,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,10 +205,10 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await action.save(picked);
|
||||
await action.save(capturedRef, [asset.id], picked);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => assetService.update([asset.id], dateTime: const Some(picked))).called(1);
|
||||
verify(() => assetService.update([asset.id], dateTime: const .some(picked))).called(1);
|
||||
verify(() => assetService.getExif(asset)).called(2);
|
||||
|
||||
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.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/favorite.action.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
@@ -27,75 +26,104 @@ void main() {
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
|
||||
|
||||
group('FavoriteAction', () {
|
||||
const action = FavoriteAction();
|
||||
|
||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
|
||||
final resolved = await tester.runAction(context, action, assets: [asset]);
|
||||
|
||||
expect(action.icon, Icons.favorite_border_rounded);
|
||||
expect(action.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);
|
||||
|
||||
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);
|
||||
|
||||
verify(() => assetService.update([mine.id], isFavorite: const Some(false))).called(1);
|
||||
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('batches every eligible owned asset into a single call', (tester) async {
|
||||
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);
|
||||
verify(() => assetService.update([first.id, second.id], isFavorite: const .some(true))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('favorites only the owned assets not already favorite', (tester) async {
|
||||
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);
|
||||
verify(() => assetService.update([stale.id], isFavorite: const .some(true))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by others', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.update([mine.id], isFavorite: const .some(true))).called(1);
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
group('UnfavoriteAction', () {
|
||||
const action = UnfavoriteAction();
|
||||
|
||||
testWidgets('unfavorite the owned assets when every one is favorite', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
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('ignores assets owned by others', (tester) async {
|
||||
final mine = owned(isFavorite: true);
|
||||
final theirs = RemoteAssetFactory.create(isFavorite: true);
|
||||
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.update([mine.id], isFavorite: const .some(false))).called(1);
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
group('toggle visibility', () {
|
||||
testWidgets('a mixed selection shows Favorite but not Unfavorite', (tester) async {
|
||||
final assets = [owned(), owned(isFavorite: true)];
|
||||
|
||||
final favorite = await tester.resolveAction(context, const FavoriteAction(), assets: assets);
|
||||
final unfavorite = await tester.resolveAction(context, const UnfavoriteAction(), assets: assets);
|
||||
|
||||
expect(favorite, isNotNull);
|
||||
expect(unfavorite, isNull);
|
||||
});
|
||||
|
||||
testWidgets('an all-favorite selection shows Unfavorite but not Favorite', (tester) async {
|
||||
final assets = [owned(isFavorite: true), owned(isFavorite: true)];
|
||||
|
||||
final favorite = await tester.resolveAction(context, const FavoriteAction(), assets: assets);
|
||||
final unfavorite = await tester.resolveAction(context, const UnfavoriteAction(), assets: assets);
|
||||
|
||||
expect(favorite, isNull);
|
||||
expect(unfavorite, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.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/lock.action.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
@@ -27,78 +26,111 @@ void main() {
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
|
||||
|
||||
group('LockAction', () {
|
||||
const action = LockAction();
|
||||
|
||||
testWidgets('locks the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
|
||||
final resolved = await tester.runAction(context, action, assets: [asset]);
|
||||
|
||||
expect(action.icon, Icons.lock_rounded);
|
||||
expect(action.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);
|
||||
|
||||
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);
|
||||
|
||||
verify(() => assetService.update([mine.id], visibility: const Some(.timeline))).called(1);
|
||||
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('batches every eligible owned asset into a single call', (tester) async {
|
||||
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);
|
||||
verify(() => assetService.update([first.id, second.id], visibility: const .some(.locked))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('locks only the owned assets not already locked', (tester) async {
|
||||
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);
|
||||
verify(() => assetService.update([stale.id], visibility: const .some(.locked))).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by others', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.update([mine.id], visibility: const .some(.locked))).called(1);
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
group('UnlockAction', () {
|
||||
const action = UnlockAction();
|
||||
|
||||
testWidgets('unlocks the owned assets when every one is locked', (tester) async {
|
||||
final asset = owned(visibility: .locked);
|
||||
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('ignores assets owned by others', (tester) async {
|
||||
final mine = owned(visibility: .locked);
|
||||
final theirs = RemoteAssetFactory.create(visibility: .locked);
|
||||
|
||||
await tester.runAction(context, action, assets: [mine, theirs]);
|
||||
|
||||
verify(() => assetService.update([mine.id], visibility: const .some(.timeline))).called(1);
|
||||
});
|
||||
|
||||
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;
|
||||
expect(message, StaticTranslations.instance.remove_from_lock_folder_action_prompt(count: 2));
|
||||
});
|
||||
});
|
||||
|
||||
group('toggle visibility', () {
|
||||
testWidgets('a mixed selection shows Lock but not Unlock', (tester) async {
|
||||
final assets = [owned(), owned(visibility: .locked)];
|
||||
|
||||
final lock = await tester.resolveAction(context, const LockAction(), assets: assets);
|
||||
final unlock = await tester.resolveAction(context, const UnlockAction(), assets: assets);
|
||||
|
||||
expect(lock, isNotNull);
|
||||
expect(unlock, isNull);
|
||||
});
|
||||
|
||||
testWidgets('an all-locked selection shows Unlock but not Lock', (tester) async {
|
||||
final assets = [owned(visibility: .locked), owned(visibility: .locked)];
|
||||
|
||||
final lock = await tester.resolveAction(context, const LockAction(), assets: assets);
|
||||
final unlock = await tester.resolveAction(context, const UnlockAction(), assets: assets);
|
||||
|
||||
expect(lock, isNull);
|
||||
expect(unlock, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
@@ -28,14 +27,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,12 +41,8 @@ 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.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
await tester.runAction(context, action, overrides: overrides(candidates: [UserFactory.create()]));
|
||||
await tester.sendKeyEvent(.escape); // dismiss without selecting
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.service.partner.create);
|
||||
@@ -57,13 +50,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 +64,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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -22,22 +22,24 @@ void main() {
|
||||
});
|
||||
|
||||
RemoteAsset owned({bool trashed = true}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null);
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? .new(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);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
|
||||
final theirs = RemoteAssetFactory.create(deletedAt: .new(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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../repository.mocks.dart';
|
||||
@@ -37,45 +36,43 @@ void main() {
|
||||
|
||||
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 action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.onSecondaryAction, isNotNull);
|
||||
expect(action.label, StaticTranslations.instance.share);
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
expect(resolved, isNotNull);
|
||||
expect(resolved!.onSecondaryAction, isNotNull);
|
||||
expect(resolved.label, StaticTranslations.instance.share);
|
||||
});
|
||||
|
||||
testWidgets('hidden when the selection is empty', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: const [], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: const []);
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
expect(resolved, isNull);
|
||||
});
|
||||
|
||||
testWidgets('uses the Android share icon on Android', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = .android;
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
|
||||
expect(resolved!.icon, Icons.share_rounded);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
|
||||
expect(action.icon, Icons.share_rounded);
|
||||
});
|
||||
|
||||
testWidgets('uses the iOS share icon on iOS', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = .iOS;
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
|
||||
expect(resolved!.icon, Icons.ios_share_rounded);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
|
||||
expect(action.icon, Icons.ios_share_rounded);
|
||||
});
|
||||
});
|
||||
|
||||
group('quality picker', () {
|
||||
testWidgets('offers both original and preview for an image', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => ShareAction(assets: [asset()], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: [asset()]);
|
||||
|
||||
unawaited(action.onSecondaryAction!());
|
||||
unawaited(resolved!.onSecondaryAction!());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.share_original), findsOneWidget);
|
||||
@@ -86,15 +83,9 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('offers only original for a video', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => ShareAction(
|
||||
assets: [asset(type: .video)],
|
||||
scope: scope,
|
||||
),
|
||||
);
|
||||
final resolved = await tester.resolveAction(context, action, assets: [asset(type: .video)]);
|
||||
|
||||
unawaited(action.onSecondaryAction!());
|
||||
unawaited(resolved!.onSecondaryAction!());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.share_original), findsOneWidget);
|
||||
@@ -109,11 +100,7 @@ void main() {
|
||||
testWidgets('shares the assets at the quality saved in settings', (tester) async {
|
||||
final target = asset();
|
||||
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
(scope) => ShareAction(assets: [target], scope: scope),
|
||||
overrides: savedQuality(.preview),
|
||||
);
|
||||
await tester.runAction(context, action, assets: [target], overrides: savedQuality(.preview));
|
||||
await tester.pump(const .new(milliseconds: 500));
|
||||
|
||||
verify(
|
||||
|
||||
@@ -25,87 +25,107 @@ void main() {
|
||||
RemoteAsset owned({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId);
|
||||
|
||||
group('StackAction', () {
|
||||
const action = 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);
|
||||
|
||||
verify(() => assetService.unstack(['stack'])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('prioritizes stack when the owned selection is mixed', (tester) async {
|
||||
testWidgets('stacks a mixed owned selection into one stack', (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);
|
||||
await tester.runAction(context, action, assets: [first, second]);
|
||||
|
||||
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
|
||||
testWidgets('ignores assets owned by others', (tester) async {
|
||||
final mine = owned();
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('is hidden for a single asset', (tester) async {
|
||||
final resolved = await tester.resolveAction(context, action, assets: [owned()]);
|
||||
|
||||
expect(resolved, isNull);
|
||||
});
|
||||
|
||||
testWidgets('reports the stacked count through the toast repository', (tester) async {
|
||||
final toast = context.repository.toast;
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
group('UnstackAction', () {
|
||||
const action = UnstackAction();
|
||||
|
||||
testWidgets('unstacks the owned assets when every one is stacked', (tester) async {
|
||||
final asset = owned(stackId: 'stack');
|
||||
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('unstacks every selected stack in a single call', (tester) async {
|
||||
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);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||
expect(message, StaticTranslations.instance.stacked_assets_count(count: 2));
|
||||
});
|
||||
|
||||
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;
|
||||
expect(message, StaticTranslations.instance.unstacked_assets_count(count: 2));
|
||||
});
|
||||
});
|
||||
|
||||
group('toggle visibility', () {
|
||||
testWidgets('a mixed selection shows Stack but not Unstack', (tester) async {
|
||||
final assets = [owned(), owned(stackId: 'stack')];
|
||||
|
||||
final stack = await tester.resolveAction(context, const StackAction(), assets: assets);
|
||||
final unstack = await tester.resolveAction(context, const UnstackAction(), assets: assets);
|
||||
|
||||
expect(stack, isNotNull);
|
||||
expect(unstack, isNull);
|
||||
});
|
||||
|
||||
testWidgets('an all-stacked selection shows Unstack but not Stack', (tester) async {
|
||||
final assets = [owned(stackId: 'stack-1'), owned(stackId: 'stack-2')];
|
||||
|
||||
final stack = await tester.resolveAction(context, const StackAction(), assets: assets);
|
||||
final unstack = await tester.resolveAction(context, const UnstackAction(), assets: assets);
|
||||
|
||||
expect(stack, isNull);
|
||||
expect(unstack, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
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(() {
|
||||
@@ -25,68 +20,21 @@ void main() {
|
||||
|
||||
RemoteAsset owned() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
|
||||
|
||||
group('TagAssetsAction', () {
|
||||
testWidgets('visible with an owned remote asset', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [owned()], scope: scope));
|
||||
const action = TagAction();
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.icon, Icons.sell_outlined);
|
||||
expect(action.label, StaticTranslations.instance.control_bottom_app_bar_add_tags);
|
||||
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 action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => TagAction(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) => TagAction(assets: [mine, theirs], scope: scope));
|
||||
|
||||
expect((action as TagAction).assetIds, [mine.id]);
|
||||
});
|
||||
|
||||
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 action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [asset], scope: scope));
|
||||
|
||||
await (action as TagAction).tagAssets(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 action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [asset], scope: scope));
|
||||
|
||||
await (action as TagAction).tagAssets(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 action = await tester.pumpActionButton(context, (scope) => TagAction(assets: [asset], scope: scope));
|
||||
|
||||
await (action as TagAction).tagAssets(selected: const {}, created: const {});
|
||||
|
||||
verifyNever(context.service.tag.bulkTagAssets);
|
||||
expect(resolved, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
@@ -10,18 +10,27 @@ 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 {
|
||||
IconData get icon => Icons.bolt;
|
||||
|
||||
@override
|
||||
String label(_) => 'fake';
|
||||
|
||||
@override
|
||||
bool isVisible(WidgetRef ref, Iterable<BaseAsset> assets) => visible;
|
||||
|
||||
@override
|
||||
Future<void> onAction(WidgetRef ref, Iterable<BaseAsset> assets) async {
|
||||
ran = true;
|
||||
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
|
||||
selectionDuringOnAction = ref.read(multiSelectProvider).isEnabled;
|
||||
if (error != null) {
|
||||
throw error!;
|
||||
}
|
||||
@@ -47,28 +56,29 @@ 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);
|
||||
}
|
||||
|
||||
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).onAction(ref, const []);
|
||||
|
||||
expect(inner.ran, isTrue);
|
||||
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
|
||||
@@ -76,24 +86,20 @@ 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 error = Exception('crash');
|
||||
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).onAction(ref, const []), 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ 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/upload.action.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
@@ -25,74 +23,24 @@ void main() {
|
||||
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: .timeline);
|
||||
|
||||
group('UploadAction', () {
|
||||
testWidgets('visible with a local asset', (tester) async {
|
||||
final action = await tester.pumpActionButton(
|
||||
context,
|
||||
(scope) => UploadAction(assets: [LocalAssetFactory.create()], scope: scope),
|
||||
);
|
||||
final resolved = await tester.resolveAction(context, action, assets: [LocalAssetFactory.create()]);
|
||||
|
||||
expect(action.isVisible, isTrue);
|
||||
expect(action.icon, Icons.backup_outlined);
|
||||
expect(action.label, StaticTranslations.instance.upload);
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
expect(resolved, isNotNull);
|
||||
expect(resolved!.icon, Icons.backup_outlined);
|
||||
expect(resolved.label, StaticTranslations.instance.upload);
|
||||
});
|
||||
|
||||
testWidgets('hidden without any local asset', (tester) async {
|
||||
final action = await tester.pumpActionButton(context, (scope) => UploadAction(assets: const [], scope: scope));
|
||||
final resolved = await tester.resolveAction(context, action, assets: const []);
|
||||
|
||||
expect(action.isVisible, isFalse);
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
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 action =
|
||||
await tester.pumpActionButton(context, (scope) => UploadAction(assets: [asset], scope: scope))
|
||||
as UploadAction;
|
||||
|
||||
await action.upload();
|
||||
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 action =
|
||||
await tester.pumpActionButton(context, (scope) => UploadAction(assets: [asset], scope: scope))
|
||||
as UploadAction;
|
||||
|
||||
await action.upload();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
verify(() => toast.error(StaticTranslations.instance.scaffold_body_error_occurred)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows the progress dialog when showProgress is true', (tester) async {
|
||||
testWidgets('shows the progress dialog when launched from the viewer', (tester) async {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final gate = Completer<void>();
|
||||
when(
|
||||
@@ -103,7 +51,8 @@ void main() {
|
||||
),
|
||||
).thenAnswer((_) => gate.future);
|
||||
|
||||
await tester.pumpTestAction(context, (scope) => UploadAction(assets: [asset], scope: scope, showProgress: true));
|
||||
final resolved = await tester.resolveAction(context, const UploadAction(source: .viewer), assets: [asset]);
|
||||
unawaited(resolved!.onAction());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(StaticTranslations.instance.uploading), findsOneWidget);
|
||||
|
||||
@@ -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,26 +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/background_sync.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/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -91,7 +93,24 @@ class PresentationContext {
|
||||
}
|
||||
}
|
||||
|
||||
typedef ResolvedAction = ({
|
||||
IconData icon,
|
||||
String label,
|
||||
Future<void> Function() onAction,
|
||||
Future<void> Function()? onSecondaryAction,
|
||||
});
|
||||
|
||||
extension PumpPresentationWidget on WidgetTester {
|
||||
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
|
||||
for (var i = 0; i < maxFrames; i++) {
|
||||
await pump();
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw StateError('pumpUntilFound: $finder not found within $maxFrames frames');
|
||||
}
|
||||
|
||||
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
@@ -120,43 +139,43 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<BaseAction> pumpTestAction(
|
||||
Future<ResolvedAction?> resolveAction(
|
||||
PresentationContext context,
|
||||
BaseAction Function(ActionScope scope) build, {
|
||||
BaseAction action, {
|
||||
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;
|
||||
ResolvedAction? resolved;
|
||||
await pumpTestWidget(
|
||||
context,
|
||||
Consumer(
|
||||
builder: (innerContext, ref, _) {
|
||||
action = build(ActionScope(context: innerContext, ref: ref, authUser: context.currentUser));
|
||||
return ActionIconButtonWidget(action: action);
|
||||
builder: (_, ref, _) {
|
||||
if (action.isVisible(ref, assets)) {
|
||||
final onSecondaryAction = action.onSecondaryAction;
|
||||
resolved = (
|
||||
icon: action.icon,
|
||||
label: action.label(ref.context),
|
||||
onAction: () => action.onAction(ref, assets),
|
||||
onSecondaryAction: onSecondaryAction == null ? null : () => onSecondaryAction(ref, assets),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
return action;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
|
||||
for (var i = 0; i < maxFrames; i++) {
|
||||
await pump();
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw StateError('pumpUntilFound: $finder not found within $maxFrames frames');
|
||||
Future<ResolvedAction?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user