mirror of
https://github.com/immich-app/immich.git
synced 2026-07-03 03:15:22 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 249259da0a | |||
| 7130553634 | |||
| d26f6c0665 |
@@ -104,4 +104,13 @@ class AssetService {
|
|||||||
await _remoteRepository.unStack(stackIds);
|
await _remoteRepository.unStack(stackIds);
|
||||||
await _apiRepository.unStack(stackIds);
|
await _apiRepository.unStack(stackIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateVisibility(List<String> remoteIds, AssetVisibility visibility) async {
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiRepository.updateVisibility(remoteIds, visibility);
|
||||||
|
await _remoteRepository.updateVisibility(remoteIds, visibility);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
class ActionScope {
|
class ActionScope {
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
@@ -14,13 +17,7 @@ class ActionScope {
|
|||||||
abstract class BaseAction {
|
abstract class BaseAction {
|
||||||
const BaseAction();
|
const BaseAction();
|
||||||
|
|
||||||
IconData get icon;
|
ActionView resolve(ActionScope scope);
|
||||||
|
|
||||||
String label(ActionScope scope);
|
|
||||||
|
|
||||||
bool isVisible(ActionScope scope) => true;
|
|
||||||
|
|
||||||
Future<void> onAction(ActionScope scope);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||||
@@ -28,5 +25,31 @@ abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
|||||||
|
|
||||||
const AssetAction({required this.assets});
|
const AssetAction({required this.assets});
|
||||||
|
|
||||||
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
|
@override
|
||||||
|
AssetActionView resolve(ActionScope scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ActionView {
|
||||||
|
final ActionScope scope;
|
||||||
|
|
||||||
|
const ActionView({required this.scope});
|
||||||
|
|
||||||
|
IconData get icon;
|
||||||
|
|
||||||
|
String get label;
|
||||||
|
|
||||||
|
bool get isVisible => true;
|
||||||
|
|
||||||
|
FutureOr<void> onAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class AssetActionView<T extends BaseAsset> extends ActionView {
|
||||||
|
final Iterable<BaseAsset> assets;
|
||||||
|
|
||||||
|
const AssetActionView({required this.assets, required super.scope});
|
||||||
|
|
||||||
|
AssetFilter<T> get filter => .new(assets.whereType<T>());
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isVisible => filter.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -7,10 +9,11 @@ import 'package:immich_mobile/utils/error_handler.dart';
|
|||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
class _ActionWidgetScope {
|
class _ActionWidgetScope {
|
||||||
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onAction;
|
final FutureOr<void> Function() onAction;
|
||||||
|
|
||||||
const _ActionWidgetScope({required this.label, required this.onAction});
|
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActionWidget extends ConsumerWidget {
|
class _ActionWidget extends ConsumerWidget {
|
||||||
@@ -19,11 +22,11 @@ class _ActionWidget extends ConsumerWidget {
|
|||||||
|
|
||||||
const _ActionWidget({required this.action, required this.builder});
|
const _ActionWidget({required this.action, required this.builder});
|
||||||
|
|
||||||
Future<void> _onAction(ActionScope scope) async {
|
Future<void> _onAction(FutureOr<void> Function() action) async {
|
||||||
try {
|
try {
|
||||||
await action.onAction(scope);
|
await action();
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +38,13 @@ class _ActionWidget extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
|
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
|
||||||
if (!action.isVisible(scope)) {
|
final view = action.resolve(scope);
|
||||||
|
|
||||||
|
if (!view.isVisible) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
|
return builder(.new(icon: view.icon, label: view.label, onAction: () => _onAction(view.onAction)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +57,7 @@ class ActionIconButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +70,7 @@ class ActionButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) =>
|
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
|
||||||
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ class ActionColumnButtonWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,6 @@ class ActionMenuItemWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _ActionWidget(
|
Widget build(BuildContext context) => _ActionWidget(
|
||||||
action: action,
|
action: action,
|
||||||
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
|
builder: (ctx) => ImmichMenuItem(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
class ArchiveAction extends AssetAction<RemoteAsset> {
|
||||||
|
const ArchiveAction({required super.assets});
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetActionView<RemoteAsset> resolve(ActionScope scope) {
|
||||||
|
final hasNonArchived = AssetFilter(assets).owned(scope.authUser.id).any((asset) => asset.visibility != .archive);
|
||||||
|
return hasNonArchived ? ArchiveView(assets: assets, scope: scope) : UnarchiveView(assets: assets, scope: scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class ArchiveView extends AssetActionView<RemoteAsset> {
|
||||||
|
const ArchiveView({required super.assets, required super.scope});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.archive_outlined;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => scope.context.t.archive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).archived(isArchived: false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isVisible => !scope.ref.watch(inLockedViewProvider) && filter.isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final ids = filter.map((asset) => asset.id).toList(growable: false);
|
||||||
|
await ref.read(assetServiceProvider).updateVisibility(ids, .archive);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.archive_action_prompt(count: ids.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class UnarchiveView extends AssetActionView<RemoteAsset> {
|
||||||
|
const UnarchiveView({required super.assets, required super.scope});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.unarchive_outlined;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => scope.context.t.unarchive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).archived();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isVisible => !scope.ref.watch(inLockedViewProvider) && filter.isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final ids = filter.map((asset) => asset.id).toList(growable: false);
|
||||||
|
await ref.read(assetServiceProvider).updateVisibility(ids, .timeline);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.unarchive_action_prompt(count: ids.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,23 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
class AssetDebugAction extends AssetAction<BaseAsset> {
|
class AssetDebugAction extends AssetAction<BaseAsset> {
|
||||||
const AssetDebugAction({required super.assets});
|
const AssetDebugAction({required super.assets});
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetDebugActionView resolve(ActionScope scope) => .new(assets: assets, scope: scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class AssetDebugActionView extends AssetActionView<BaseAsset> {
|
||||||
|
const AssetDebugActionView({required super.assets, required super.scope});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.help_outline_rounded;
|
IconData get icon => Icons.help_outline_rounded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => scope.context.t.troubleshoot;
|
String get label => scope.context.t.troubleshoot;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isVisible(ActionScope scope) =>
|
bool get isVisible => scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting) && assets.length == 1;
|
||||||
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onAction(ActionScope scope) async =>
|
Future<void> onAction() async => unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||||
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,33 +6,58 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
class FavoriteAction extends BaseAction {
|
||||||
final bool favorite;
|
final Iterable<BaseAsset> assets;
|
||||||
|
|
||||||
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
|
const FavoriteAction({required this.assets});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
AssetActionView<RemoteAsset> resolve(ActionScope scope) {
|
||||||
|
final hasNonFavorite = AssetFilter(assets).owned(scope.authUser.id).any((asset) => !asset.isFavorite);
|
||||||
@override
|
return hasNonFavorite ? FavoriteView(assets: assets, scope: scope) : UnfavoriteView(assets: assets, scope: scope);
|
||||||
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
}
|
||||||
|
}
|
||||||
@override
|
|
||||||
Iterable<RemoteAsset> filter(ActionScope scope) =>
|
@visibleForTesting
|
||||||
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
|
class FavoriteView extends AssetActionView<RemoteAsset> {
|
||||||
|
const FavoriteView({required super.assets, required super.scope});
|
||||||
@override
|
|
||||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
@override
|
||||||
|
IconData get icon => Icons.favorite_border_rounded;
|
||||||
@override
|
|
||||||
Future<void> onAction(ActionScope scope) async {
|
@override
|
||||||
final ActionScope(:ref, :context) = scope;
|
String get label => scope.context.t.favorite;
|
||||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
|
||||||
|
@override
|
||||||
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).favorite(isFavorite: false);
|
||||||
final message = favorite
|
|
||||||
? context.t.favorite_action_prompt(count: assets.length)
|
@override
|
||||||
: context.t.unfavorite_action_prompt(count: assets.length);
|
Future<void> onAction() async {
|
||||||
ref.read(toastRepositoryProvider).success(message);
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final ids = filter.map((asset) => asset.id).toList(growable: false);
|
||||||
|
await ref.read(assetServiceProvider).updateFavorite(ids, true);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.favorite_action_prompt(count: ids.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class UnfavoriteView extends AssetActionView<RemoteAsset> {
|
||||||
|
const UnfavoriteView({required super.assets, required super.scope});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.favorite_rounded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => scope.context.t.unfavorite;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).favorite();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final ids = filter.map((asset) => asset.id).toList(growable: false);
|
||||||
|
await ref.read(assetServiceProvider).updateFavorite(ids, false);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.unfavorite_action_prompt(count: ids.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
|||||||
class PartnerAddAction extends BaseAction {
|
class PartnerAddAction extends BaseAction {
|
||||||
const PartnerAddAction();
|
const PartnerAddAction();
|
||||||
|
|
||||||
|
@override
|
||||||
|
PartnerAddActionView resolve(ActionScope scope) => .new(scope: scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class PartnerAddActionView extends ActionView {
|
||||||
|
const PartnerAddActionView({required super.scope});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.person_add_rounded;
|
IconData get icon => Icons.person_add_rounded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => scope.context.t.add_partner;
|
String get label => scope.context.t.add_partner;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onAction(ActionScope scope) async {
|
Future<void> onAction() async {
|
||||||
final ActionScope(:context, :ref, :authUser) = scope;
|
final ActionScope(:context, :ref, :authUser) = scope;
|
||||||
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
@@ -35,14 +43,26 @@ class PartnerRemoveAction extends BaseAction {
|
|||||||
final String sharedWithId;
|
final String sharedWithId;
|
||||||
final String partnerName;
|
final String partnerName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PartnerRemoveActionView resolve(ActionScope scope) =>
|
||||||
|
.new(sharedWithId: sharedWithId, partnerName: partnerName, scope: scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class PartnerRemoveActionView extends ActionView {
|
||||||
|
final String sharedWithId;
|
||||||
|
final String partnerName;
|
||||||
|
|
||||||
|
const PartnerRemoveActionView({required this.sharedWithId, required this.partnerName, required super.scope});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.person_remove_rounded;
|
IconData get icon => Icons.person_remove_rounded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => scope.context.t.remove;
|
String get label => scope.context.t.remove;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onAction(ActionScope scope) async {
|
Future<void> onAction() async {
|
||||||
final ActionScope(:context, :ref, :authUser) = scope;
|
final ActionScope(:context, :ref, :authUser) = scope;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
|
|||||||
@@ -9,22 +9,27 @@ import 'package:immich_mobile/utils/asset_filter.dart';
|
|||||||
class RestoreAction extends AssetAction<RemoteAsset> {
|
class RestoreAction extends AssetAction<RemoteAsset> {
|
||||||
const RestoreAction({required super.assets});
|
const RestoreAction({required super.assets});
|
||||||
|
|
||||||
|
@override
|
||||||
|
RestoreActionView resolve(ActionScope scope) => .new(assets: assets, scope: scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class RestoreActionView extends AssetActionView<RemoteAsset> {
|
||||||
|
const RestoreActionView({required super.assets, required super.scope});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.history_rounded;
|
IconData get icon => Icons.history_rounded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => scope.context.t.restore;
|
String get label => scope.context.t.restore;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id).trashed();
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id).trashed();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
Future<void> onAction() async {
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onAction(ActionScope scope) async {
|
|
||||||
final ActionScope(:ref, :context) = scope;
|
final ActionScope(:ref, :context) = scope;
|
||||||
final ids = filter(scope).map((asset) => asset.id).toList(growable: false);
|
final ids = filter.map((asset) => asset.id).toList(growable: false);
|
||||||
await ref.read(assetServiceProvider).restoreTrash(ids);
|
await ref.read(assetServiceProvider).restoreTrash(ids);
|
||||||
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: ids.length));
|
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: ids.length));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,37 +7,59 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
|||||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
class StackAction extends AssetAction<RemoteAsset> {
|
class StackAction extends AssetAction<RemoteAsset> {
|
||||||
final bool stack;
|
const StackAction({required super.assets});
|
||||||
|
|
||||||
StackAction({required super.assets}) : stack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
|
AssetActionView<RemoteAsset> resolve(ActionScope scope) {
|
||||||
|
final unstacked = AssetFilter(assets).owned(scope.authUser.id).any((asset) => asset.stackId == null);
|
||||||
@override
|
return unstacked ? StackView(assets: assets, scope: scope) : UnstackView(assets: assets, scope: scope);
|
||||||
String label(ActionScope scope) => stack ? scope.context.t.stack : scope.context.t.unstack;
|
}
|
||||||
|
}
|
||||||
@override
|
|
||||||
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id);
|
@visibleForTesting
|
||||||
|
class StackView extends AssetActionView<RemoteAsset> {
|
||||||
@override
|
const StackView({required super.assets, required super.scope});
|
||||||
bool isVisible(ActionScope scope) => stack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
|
|
||||||
|
@override
|
||||||
@override
|
IconData get icon => Icons.filter_none_rounded;
|
||||||
Future<void> onAction(ActionScope scope) async {
|
|
||||||
final ActionScope(:ref, :context) = scope;
|
@override
|
||||||
final assets = filter(scope).toList(growable: false);
|
String get label => scope.context.t.stack;
|
||||||
final service = ref.read(assetServiceProvider);
|
|
||||||
|
@override
|
||||||
if (stack) {
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id);
|
||||||
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
|
|
||||||
} else {
|
@override
|
||||||
await service.unstack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
|
bool get isVisible => filter.length > 1;
|
||||||
}
|
|
||||||
|
@override
|
||||||
final message = stack
|
Future<void> onAction() async {
|
||||||
? context.t.stacked_assets_count(count: assets.length)
|
final ActionScope(:ref, :context) = scope;
|
||||||
: context.t.unstacked_assets_count(count: assets.length);
|
final ids = filter.map((asset) => asset.id).toList(growable: false);
|
||||||
ref.read(toastRepositoryProvider).success(message);
|
await ref.read(assetServiceProvider).stack(scope.authUser.id, ids);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.stacked_assets_count(count: ids.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class UnstackView extends AssetActionView<RemoteAsset> {
|
||||||
|
const UnstackView({required super.assets, required super.scope});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.layers_clear_outlined;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => scope.context.t.unstack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AssetFilter<RemoteAsset> get filter => .new(assets).owned(scope.authUser.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final assets = filter.toList(growable: false);
|
||||||
|
final stackIds = assets.map((asset) => asset.stackId).nonNulls.toList(growable: false);
|
||||||
|
await ref.read(assetServiceProvider).unstack(stackIds);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.unstacked_assets_count(count: assets.length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ class TimelineAction extends BaseAction {
|
|||||||
const TimelineAction({required this.action});
|
const TimelineAction({required this.action});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => action.icon;
|
TimelineActionView resolve(ActionScope scope) => .new(view: action.resolve(scope), scope: scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
class TimelineActionView extends ActionView {
|
||||||
|
final ActionView view;
|
||||||
|
|
||||||
|
const TimelineActionView({required this.view, required super.scope});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => action.label(scope);
|
IconData get icon => view.icon;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isVisible(ActionScope scope) => action.isVisible(scope);
|
String get label => view.label;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onAction(ActionScope scope) async {
|
bool get isVisible => view.isVisible;
|
||||||
await action.onAction(scope);
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction() async {
|
||||||
|
await view.onAction();
|
||||||
scope.ref.read(multiSelectProvider.notifier).reset();
|
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
|
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.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/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
enum AddToMenuItem { album, lockedFolder }
|
||||||
|
|
||||||
class AddActionButton extends ConsumerStatefulWidget {
|
class AddActionButton extends ConsumerStatefulWidget {
|
||||||
const AddActionButton({super.key, this.originalTheme});
|
const AddActionButton({super.key, this.originalTheme});
|
||||||
@@ -37,12 +35,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
case AddToMenuItem.album:
|
case AddToMenuItem.album:
|
||||||
_openAlbumSelector();
|
_openAlbumSelector();
|
||||||
break;
|
break;
|
||||||
case AddToMenuItem.archive:
|
|
||||||
performArchiveAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.unarchive:
|
|
||||||
performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.lockedFolder:
|
case AddToMenuItem.lockedFolder:
|
||||||
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||||
break;
|
break;
|
||||||
@@ -57,11 +49,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
|
|
||||||
final user = ref.read(currentUserProvider);
|
final user = ref.read(currentUserProvider);
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
|
||||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
|
||||||
final hasRemote = asset is RemoteAsset;
|
|
||||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
|
||||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -81,20 +68,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||||
),
|
),
|
||||||
if (showArchive)
|
ActionMenuItemWidget(action: ArchiveAction(assets: [asset])),
|
||||||
BaseActionButton(
|
|
||||||
iconData: Icons.archive_outlined,
|
|
||||||
label: "archive".tr(),
|
|
||||||
menuItem: true,
|
|
||||||
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
|
|
||||||
),
|
|
||||||
if (showUnarchive)
|
|
||||||
BaseActionButton(
|
|
||||||
iconData: Icons.unarchive_outlined,
|
|
||||||
label: "unarchive".tr(),
|
|
||||||
menuItem: true,
|
|
||||||
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
|
|
||||||
),
|
|
||||||
BaseActionButton(
|
BaseActionButton(
|
||||||
iconData: Icons.lock_outline,
|
iconData: Icons.lock_outline,
|
||||||
label: "locked_folder".tr(),
|
label: "locked_folder".tr(),
|
||||||
@@ -184,7 +158,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
|
|
||||||
final themeData = widget.originalTheme ?? context.themeData;
|
final themeData = widget.originalTheme ?? context.themeData;
|
||||||
|
|
||||||
return MenuAnchor(
|
return ImmichMenu(
|
||||||
consumeOutsideTap: true,
|
consumeOutsideTap: true,
|
||||||
style: MenuStyle(
|
style: MenuStyle(
|
||||||
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
|
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
|
||||||
@@ -195,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
),
|
),
|
||||||
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
||||||
),
|
),
|
||||||
menuChildren: widget.originalTheme != null
|
children: widget.originalTheme != null
|
||||||
? [
|
? [
|
||||||
Theme(
|
Theme(
|
||||||
data: widget.originalTheme!,
|
data: widget.originalTheme!,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
// used to allow performing archive action from different sources (without duplicating code)
|
|
||||||
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ArchiveActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
await performArchiveAction(context, ref, source: source);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.archive_outlined,
|
|
||||||
label: "to_archive".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// dart
|
|
||||||
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
||||||
|
|
||||||
// used to allow performing unarchive action from different sources (without duplicating code)
|
|
||||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UnArchiveActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
await performUnArchiveAction(context, ref, source: source);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.unarchive_outlined,
|
|
||||||
label: "unarchive".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.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/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
@@ -16,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
@@ -76,7 +76,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
|
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -87,7 +87,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
|
||||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ 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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
@@ -67,7 +67,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
|
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
initialChildSize: 0.4,
|
initialChildSize: 0.4,
|
||||||
@@ -78,7 +78,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
@@ -83,7 +83,12 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [AssetDebugAction(assets: assets), FavoriteAction(assets: assets), StackAction(assets: assets)];
|
final actions = [
|
||||||
|
AssetDebugAction(assets: assets),
|
||||||
|
FavoriteAction(assets: assets),
|
||||||
|
ArchiveAction(assets: assets),
|
||||||
|
StackAction(assets: assets),
|
||||||
|
];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -100,7 +105,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
|
||||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
@@ -85,7 +85,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
|
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -99,7 +99,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
|
|
||||||
if (ownsAlbum) ...[
|
if (ownsAlbum) ...[
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
|
||||||
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
],
|
],
|
||||||
const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -156,28 +156,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> archive(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
await _service.archive(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to archive assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> unArchive(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
await _service.unArchive(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to unarchive assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
final ids = _getOwnedRemoteIdsForSource(source);
|
||||||
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
|
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
|
||||||
|
|||||||
@@ -68,16 +68,6 @@ class ActionService {
|
|||||||
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> archive(List<String> remoteIds) async {
|
|
||||||
await _assetApiRepository.updateVisibility(remoteIds, .archive);
|
|
||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> unArchive(List<String> remoteIds) async {
|
|
||||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
|
||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||||
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import 'package:immich_mobile/domain/models/events.model.dart';
|
|||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
@@ -30,7 +30,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
@@ -200,12 +199,8 @@ enum ActionButtonType {
|
|||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
),
|
),
|
||||||
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.archive ||
|
||||||
ActionButtonType.unarchive => UnArchiveActionButton(
|
ActionButtonType.unarchive => ActionMenuItemWidget(action: ArchiveAction(assets: [context.asset])),
|
||||||
source: context.source,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
|
||||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.restoreTrash => ActionMenuItemWidget(action: RestoreAction(assets: [context.asset])),
|
ActionButtonType.restoreTrash => ActionMenuItemWidget(action: RestoreAction(assets: [context.asset])),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
|
|||||||
// ignore: depend_on_referenced_packages
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:stack_trace/stack_trace.dart';
|
import 'package:stack_trace/stack_trace.dart';
|
||||||
|
|
||||||
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
|
void handleError(Object error, {StackTrace? stack, String? description}) {
|
||||||
String? stackTrace;
|
String? stackTrace;
|
||||||
if (stack != null) {
|
if (stack != null) {
|
||||||
final trace = Trace.from(stack);
|
final trace = Trace.from(stack);
|
||||||
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
|
|||||||
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
|
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
if (serverErrorMessage(error) case String serverMessage) {
|
if (serverErrorMessage(error) case String serverMessage) {
|
||||||
message = serverMessage;
|
message = serverMessage;
|
||||||
} else if (isConnectionError(error)) {
|
} else if (isConnectionError(error)) {
|
||||||
message = context.t.login_form_server_error;
|
message = StaticTranslations.instance.login_form_server_error;
|
||||||
} else {
|
} else {
|
||||||
message = context.t.scaffold_body_error_occurred;
|
message = StaticTranslations.instance.scaffold_body_error_occurred;
|
||||||
}
|
}
|
||||||
|
|
||||||
snackbar.error(message);
|
snackbar.error(message);
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class ServiceMocks {
|
|||||||
when(asset.stack).thenAnswer((_) async {});
|
when(asset.stack).thenAnswer((_) async {});
|
||||||
when(asset.unstack).thenAnswer((_) async {});
|
when(asset.unstack).thenAnswer((_) async {});
|
||||||
when(asset.restoreTrash).thenAnswer((_) async {});
|
when(asset.restoreTrash).thenAnswer((_) async {});
|
||||||
|
when(asset.updateVisibility).thenAnswer((_) async {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ void _registerFallbacks() {
|
|||||||
registerFallbackValue(LocalAlbumFactory.create());
|
registerFallbackValue(LocalAlbumFactory.create());
|
||||||
registerFallbackValue(LocalAssetFactory.create());
|
registerFallbackValue(LocalAssetFactory.create());
|
||||||
registerFallbackValue(Uint8List(0));
|
registerFallbackValue(Uint8List(0));
|
||||||
|
registerFallbackValue(AssetVisibility.timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||||
@@ -181,6 +183,9 @@ extension type const AssetServiceStub(MockAssetService service) implements Stub<
|
|||||||
|
|
||||||
Future<void> Function() get restoreTrash =>
|
Future<void> Function() get restoreTrash =>
|
||||||
() => service.restoreTrash(any());
|
() => service.restoreTrash(any());
|
||||||
|
|
||||||
|
Future<void> Function() get updateVisibility =>
|
||||||
|
() => service.updateVisibility(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/archive.action.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
late MockAssetService assetService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
assetService = context.service.asset.service;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset owned({AssetVisibility visibility = AssetVisibility.timeline}) =>
|
||||||
|
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
|
||||||
|
|
||||||
|
group('ArchiveAction', () {
|
||||||
|
testWidgets('archives the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
final view = await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
|
||||||
|
|
||||||
|
expect(view, isA<ArchiveView>());
|
||||||
|
expect(view.icon, Icons.archive_outlined);
|
||||||
|
expect(view.label, StaticTranslations.instance.archive);
|
||||||
|
|
||||||
|
verify(() => assetService.updateVisibility([asset.id], .archive)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('unarchive the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned(visibility: .archive);
|
||||||
|
final view = await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
|
||||||
|
|
||||||
|
expect(view, isA<UnarchiveView>());
|
||||||
|
expect(view.icon, Icons.unarchive_outlined);
|
||||||
|
expect(view.label, StaticTranslations.instance.unarchive);
|
||||||
|
|
||||||
|
verify(() => assetService.updateVisibility([asset.id], .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 view = await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs]));
|
||||||
|
expect(view, isA<UnarchiveView>());
|
||||||
|
|
||||||
|
verify(() => assetService.updateVisibility([mine.id], .timeline)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||||
|
final first = owned();
|
||||||
|
final second = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, ArchiveAction(assets: [first, second]));
|
||||||
|
|
||||||
|
verify(() => assetService.updateVisibility([first.id, second.id], .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, ArchiveAction(assets: [stale, alreadyArchived]));
|
||||||
|
|
||||||
|
verify(() => assetService.updateVisibility([stale.id], .archive)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the archived count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, ArchiveAction(assets: [owned(), owned()]));
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.archive_action_prompt(count: 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the unarchive count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
ArchiveAction(
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
@@ -27,27 +28,33 @@ void main() {
|
|||||||
group('FavoriteAction', () {
|
group('FavoriteAction', () {
|
||||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||||
final asset = owned();
|
final asset = owned();
|
||||||
|
final view = await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||||
|
|
||||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
expect(view, isA<FavoriteView>());
|
||||||
|
expect(view.icon, Icons.favorite_border_rounded);
|
||||||
|
expect(view.label, StaticTranslations.instance.favorite);
|
||||||
|
|
||||||
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
|
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
||||||
final asset = owned(isFavorite: true);
|
final asset = owned(isFavorite: true);
|
||||||
|
final view = await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||||
|
|
||||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
expect(view, isA<UnfavoriteView>());
|
||||||
|
expect(view.icon, Icons.favorite_rounded);
|
||||||
|
expect(view.label, StaticTranslations.instance.unfavorite);
|
||||||
|
|
||||||
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
|
||||||
final mine = owned();
|
final mine = owned(isFavorite: true);
|
||||||
final theirs = RemoteAssetFactory.create();
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
final view = await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
|
||||||
|
expect(view, isA<UnfavoriteView>());
|
||||||
|
|
||||||
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
|
verify(() => assetService.updateFavorite([mine.id], false)).called(1);
|
||||||
|
|
||||||
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||||
@@ -59,7 +66,7 @@ void main() {
|
|||||||
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
|
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('skips owned assets already in the target state', (tester) async {
|
testWidgets('favorites only the owned assets not already favorite', (tester) async {
|
||||||
final stale = owned();
|
final stale = owned();
|
||||||
final alreadyFavorite = owned(isFavorite: true);
|
final alreadyFavorite = owned(isFavorite: true);
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ void main() {
|
|||||||
verify(() => assetService.restoreTrash([mine.id])).called(1);
|
verify(() => assetService.restoreTrash([mine.id])).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('skips owned assets that are not trashed', (tester) async {
|
testWidgets('restores only the owned assets that are trashed', (tester) async {
|
||||||
final trashed = owned();
|
final trashed = owned();
|
||||||
final live = owned(trashed: false);
|
final live = owned(trashed: false);
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('reports success through the toast repository with the restored count', (tester) async {
|
testWidgets('reports success through the toast repository with the restored count', (tester) async {
|
||||||
final toast = context.repository.toast;
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
await tester.pumpTestAction(context, RestoreAction(assets: [owned(), owned()]));
|
await tester.pumpTestAction(context, RestoreAction(assets: [owned(), owned()]));
|
||||||
|
|
||||||
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
@@ -27,30 +28,37 @@ void main() {
|
|||||||
testWidgets('stacks the eligible owned assets', (tester) async {
|
testWidgets('stacks the eligible owned assets', (tester) async {
|
||||||
final first = owned();
|
final first = owned();
|
||||||
final second = owned();
|
final second = owned();
|
||||||
|
final view = await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
||||||
|
|
||||||
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
expect(view, isA<StackView>());
|
||||||
|
expect(view.icon, Icons.filter_none_rounded);
|
||||||
|
expect(view.label, StaticTranslations.instance.stack);
|
||||||
|
|
||||||
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('unstacks the eligible owned assets', (tester) async {
|
testWidgets('unstacks the eligible owned assets', (tester) async {
|
||||||
final asset = owned(stackId: 'stack');
|
final asset = owned(stackId: 'stack');
|
||||||
|
final view = await tester.pumpTestAction(context, StackAction(assets: [asset]));
|
||||||
|
|
||||||
await tester.pumpTestAction(context, StackAction(assets: [asset]));
|
expect(view, isA<UnstackView>());
|
||||||
|
expect(view.icon, Icons.layers_clear_outlined);
|
||||||
|
expect(view.label, StaticTranslations.instance.unstack);
|
||||||
|
|
||||||
verify(() => assetService.unstack(['stack'])).called(1);
|
verify(() => assetService.unstack(['stack'])).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('prioritizes stack when mixed state', (tester) async {
|
testWidgets('prioritizes stack when the owned selection is mixed', (tester) async {
|
||||||
final first = owned();
|
final first = owned();
|
||||||
final second = owned(stackId: 'stack');
|
final second = owned(stackId: 'stack');
|
||||||
|
final view = await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
||||||
|
|
||||||
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
expect(view, isA<StackView>());
|
||||||
|
|
||||||
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
|
||||||
final mine = owned();
|
final mine = owned();
|
||||||
final other = owned();
|
final other = owned();
|
||||||
final theirs = RemoteAssetFactory.create();
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
|||||||
@@ -18,21 +18,30 @@ class _FakeAction extends BaseAction {
|
|||||||
bool ran = false;
|
bool ran = false;
|
||||||
bool? selectionDuringOnAction;
|
bool? selectionDuringOnAction;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ActionView resolve(ActionScope scope) => _FakeActionView(this, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeActionView extends ActionView {
|
||||||
|
final _FakeAction action;
|
||||||
|
|
||||||
|
_FakeActionView(this.action, ActionScope scope) : super(scope: scope);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.bolt;
|
IconData get icon => Icons.bolt;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => 'fake';
|
String get label => 'fake';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isVisible(ActionScope scope) => visible;
|
bool get isVisible => action.visible;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onAction(ActionScope scope) async {
|
Future<void> onAction() async {
|
||||||
ran = true;
|
action.ran = true;
|
||||||
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
|
action.selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
|
||||||
if (error != null) {
|
if (action.error != null) {
|
||||||
throw error!;
|
throw action.error!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +86,7 @@ void main() {
|
|||||||
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
|
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
|
||||||
final inner = _FakeAction();
|
final inner = _FakeAction();
|
||||||
final (scope, container) = await pumpScope(tester);
|
final (scope, container) = await pumpScope(tester);
|
||||||
await TimelineAction(action: inner).onAction(scope);
|
await TimelineAction(action: inner).resolve(scope).onAction();
|
||||||
|
|
||||||
expect(inner.ran, isTrue);
|
expect(inner.ran, isTrue);
|
||||||
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
|
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
|
||||||
@@ -89,7 +98,7 @@ void main() {
|
|||||||
final inner = _FakeAction(error: error);
|
final inner = _FakeAction(error: error);
|
||||||
final (scope, container) = await pumpScope(tester);
|
final (scope, container) = await pumpScope(tester);
|
||||||
|
|
||||||
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
|
await expectLater(TimelineAction(action: inner).resolve(scope).onAction(), throwsA(same(error)));
|
||||||
|
|
||||||
expect(inner.ran, isTrue);
|
expect(inner.ran, isTrue);
|
||||||
expect(container.read(multiSelectProvider).isEnabled, isTrue);
|
expect(container.read(multiSelectProvider).isEnabled, isTrue);
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ void main() {
|
|||||||
|
|
||||||
group('PartnerSharedByList', () {
|
group('PartnerSharedByList', () {
|
||||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||||
final action = const PartnerAddAction();
|
|
||||||
|
|
||||||
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
|
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
|
||||||
|
|
||||||
expect(find.byType(ListView), findsNothing);
|
expect(find.byType(ListView), findsNothing);
|
||||||
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
|
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders a tile per partner with name and email', (tester) async {
|
testWidgets('renders a tile per partner with name and email', (tester) async {
|
||||||
@@ -39,9 +37,8 @@ void main() {
|
|||||||
testWidgets('renders a remove action for each partner', (tester) async {
|
testWidgets('renders a remove action for each partner', (tester) async {
|
||||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||||
final partner2 = PartnerFactory.create();
|
final partner2 = PartnerFactory.create();
|
||||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
|
||||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -98,14 +98,25 @@ extension PumpPresentationWidget on WidgetTester {
|
|||||||
await pumpAndSettle();
|
await pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pumpTestAction(
|
Future<ActionView> pumpTestAction(
|
||||||
PresentationContext context,
|
PresentationContext context,
|
||||||
BaseAction action, {
|
BaseAction action, {
|
||||||
List<Override> overrides = const [],
|
List<Override> overrides = const [],
|
||||||
}) async {
|
}) async {
|
||||||
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
|
late ActionView view;
|
||||||
|
await pumpTestWidget(
|
||||||
|
context,
|
||||||
|
Consumer(
|
||||||
|
builder: (innerContext, ref, _) {
|
||||||
|
view = action.resolve(ActionScope(context: innerContext, ref: ref, authUser: context.currentUser));
|
||||||
|
return ActionIconButtonWidget(action: action);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
overrides: overrides,
|
||||||
|
);
|
||||||
await tap(find.byType(ImmichIconButton));
|
await tap(find.byType(ImmichIconButton));
|
||||||
await pump();
|
await pump();
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
|
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user