mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(server): trash asset (#4015)
* refactor(server): delete assets endpoint * fix: formatting * chore: cleanup * chore: open api * chore(mobile): replace DeleteAssetDTO with BulkIdsDTOs * feat: trash an asset * chore(server): formatting * chore: open api * chore: wording * chore: open-api * feat(server): add withDeleted to getAssets queries * WIP: mobile-recycle-bin * feat(server): recycle-bin to system config * feat(web): use recycle-bin system config * chore(server): domain assetcore removed * chore(server): rename recycle-bin to trash * chore(web): rename recycle-bin to trash * chore(server): always send soft deleted assets for getAllByUserId * chore(web): formatting * feat(server): permanent delete assets older than trashed period * feat(web): trash empty placeholder image * feat(server): empty trash * feat(web): empty trash * WIP: mobile-recycle-bin * refactor(server): empty / restore trash to separate endpoint * test(server): handle failures * test(server): fix e2e server-info test * test(server): deletion test refactor * feat(mobile): use map settings from server-config to enable / disable map * feat(mobile): trash asset * fix(server): operations on assets in trash * feat(web): show trash statistics * fix(web): handle trash enabled * fix(mobile): restore updates from trash * fix(server): ignore trashed assets for person * fix(server): add / remove search index when trashed / restored * chore(web): format * fix(server): asset service test * fix(server): include trashed assts for duplicates from uploads * feat(mobile): no dialog for trash, always dialog for permanent delete * refactor(mobile): use isar where instead of dart filter * refactor(mobile): asset provide - handle deletes in single db txn * chore(mobile): review changes * feat(web): confirmation before empty trash * server: review changes * fix(server): handle library changes * fix: filter external assets from getting trashed / deleted * fix(server): empty-bin * feat: broadcast config update events through ws * change order of trash button on mobile * styling * fix(mobile): do not show trashed toast for local only assets --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
fc93762230
commit
4a8887f37b
117 changed files with 3155 additions and 928 deletions
|
|
@ -17,6 +17,7 @@ final archiveProvider = StreamProvider<RenderList>((ref) async* {
|
|||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.filter()
|
||||
.isArchivedEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAt();
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||
|
|
@ -19,11 +20,14 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'
|
|||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
|
|
@ -67,6 +71,12 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final header = {"Authorization": authToken};
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
final isTrashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final navStack = AutoRouter.of(context).stackData;
|
||||
final isFromTrash = isTrashEnabled &&
|
||||
navStack.length > 2 &&
|
||||
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
||||
|
||||
Asset asset() => currentAsset;
|
||||
|
||||
|
|
@ -161,25 +171,47 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void handleDelete(Asset deleteAsset) {
|
||||
void handleDelete(Asset deleteAsset) async {
|
||||
Future<bool> onDelete(bool force) async {
|
||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||
{deleteAsset},
|
||||
force: force,
|
||||
);
|
||||
if (isDeleted) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
// Go to next page otherwise
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
return isDeleted;
|
||||
}
|
||||
|
||||
// Asset is trashed
|
||||
if (isTrashEnabled && !isFromTrash) {
|
||||
final isDeleted = await onDelete(false);
|
||||
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
||||
if (context.mounted && isDeleted && deleteAsset.isRemote) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'Asset trashed',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Asset is permanently removed
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
// Go to next page otherwise
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
|
||||
},
|
||||
);
|
||||
return DeleteDialog(onDelete: () => onDelete(true));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||
context: context,
|
||||
msg: "Deleting ${assets.length} assets on the server...",
|
||||
);
|
||||
await ref.read(assetProvider.notifier).deleteAssets(assets);
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(assets, force: true);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Deleted ${assets.length} assets on the server. "
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
|
|||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.filter()
|
||||
.isFavoriteEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAt();
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
|||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
|
|
@ -43,6 +44,8 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var hasRemote = selectionAssetState == AssetState.remote;
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
Widget renderActionButtons() {
|
||||
return Row(
|
||||
|
|
@ -70,14 +73,20 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
iconData: Icons.delete_outline_rounded,
|
||||
label: "control_bottom_app_bar_delete".tr(),
|
||||
onPressed: enabled
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
)
|
||||
? () {
|
||||
if (!trashEnabled) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (!hasRemote)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dar
|
|||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
|
||||
class ProfileDrawer extends HookConsumerWidget {
|
||||
|
|
@ -16,6 +17,9 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
buildSignOutButton() {
|
||||
return ListTile(
|
||||
leading: SizedBox(
|
||||
|
|
@ -91,6 +95,29 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
buildTrashButton() {
|
||||
return ListTile(
|
||||
leading: SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(
|
||||
Icons.delete_rounded,
|
||||
color: Theme.of(context).textTheme.labelMedium?.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"Trash",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(const TrashRoute());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Drawer(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
|
|
@ -105,6 +132,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
|||
const ProfileDrawerHeader(),
|
||||
buildSettingButton(),
|
||||
buildAppLogButton(),
|
||||
if (trashEnabled) buildTrashButton(),
|
||||
buildSignOutButton(),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ class HomePage extends HookConsumerWidget {
|
|||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
final tipOneOpacity = useState(0.0);
|
||||
final refreshCount = useState(0);
|
||||
|
|
@ -139,7 +141,21 @@ class HomePage extends HookConsumerWidget {
|
|||
void onDelete() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
await ref.read(assetProvider.notifier).deleteAssets(selection.value);
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(selection.value, force: !trashEnabled);
|
||||
|
||||
final hasRemote = selection.value.any((a) => a.isRemote);
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
final trashOrRemoved =
|
||||
!trashEnabled ? 'deleted permanently' : 'trashed';
|
||||
if (hasRemote) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
selectionEnabledHook.value = false;
|
||||
} finally {
|
||||
processing.value = false;
|
||||
|
|
|
|||
144
mobile/lib/modules/trash/providers/trashed_asset.provider.dart
Normal file
144
mobile/lib/modules/trash/providers/trashed_asset.provider.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/trash/services/trash.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TrashNotifier extends StateNotifier<bool> {
|
||||
final Isar _db;
|
||||
final Ref _ref;
|
||||
final TrashService _trashService;
|
||||
final _log = Logger('TrashNotifier');
|
||||
|
||||
TrashNotifier(
|
||||
this._trashService,
|
||||
this._db,
|
||||
this._ref,
|
||||
) : super(false);
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
await _trashService.emptyTrash();
|
||||
|
||||
final dbIds = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.idProperty()
|
||||
.findAll();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.exifInfos.deleteAll(dbIds);
|
||||
await _db.assets.deleteAll(dbIds);
|
||||
});
|
||||
|
||||
// Refresh assets in background
|
||||
Future.delayed(
|
||||
const Duration(seconds: 4),
|
||||
() async => await _ref.read(assetProvider.notifier).getAllAsset(),
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
final result = await _trashService.restoreAssets(assetList);
|
||||
|
||||
if (result) {
|
||||
final remoteAssets = assetList.where((a) => a.isRemote).toList();
|
||||
|
||||
final updatedAssets = remoteAssets.map((e) {
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
|
||||
// Refresh assets in background
|
||||
Future.delayed(
|
||||
const Duration(seconds: 4),
|
||||
() async => await _ref.read(assetProvider.notifier).getAllAsset(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
await _trashService.restoreTrash();
|
||||
|
||||
final assets = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.findAll();
|
||||
|
||||
final updatedAssets = assets.map((e) {
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
|
||||
// Refresh assets in background
|
||||
Future.delayed(
|
||||
const Duration(seconds: 4),
|
||||
() async => await _ref.read(assetProvider.notifier).getAllAsset(),
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
|
||||
return TrashNotifier(
|
||||
ref.watch(trashServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
final trashedAssetsProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return;
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.sortByFileCreatedAt();
|
||||
const groupBy = GroupAssetsBy.none;
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
await for (final _ in query.watchLazy()) {
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
}
|
||||
});
|
||||
48
mobile/lib/modules/trash/services/trash.service.dart
Normal file
48
mobile/lib/modules/trash/services/trash.service.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final trashServiceProvider = Provider<TrashService>((ref) {
|
||||
return TrashService(
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class TrashService {
|
||||
final _log = Logger("TrashService");
|
||||
|
||||
final ApiService _apiService;
|
||||
|
||||
TrashService(this._apiService);
|
||||
|
||||
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
List<String> remoteIds =
|
||||
assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList();
|
||||
await _apiService.assetApi.restoreAssets(BulkIdsDto(ids: remoteIds));
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore assets ${error.toString()}", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
try {
|
||||
await _apiService.assetApi.emptyTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
try {
|
||||
await _apiService.assetApi.restoreTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
276
mobile/lib/modules/trash/views/trash_page.dart
Normal file
276
mobile/lib/modules/trash/views/trash_page.dart
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/trash/providers/trashed_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class TrashPage extends HookConsumerWidget {
|
||||
const TrashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashedAssets = ref.watch(trashedAssetsProvider);
|
||||
final trashDays =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selection = useState(<Asset>{});
|
||||
final processing = useState(false);
|
||||
|
||||
void selectionListener(
|
||||
bool multiselect,
|
||||
Set<Asset> selectedAssets,
|
||||
) {
|
||||
selectionEnabledHook.value = multiselect;
|
||||
selection.value = selectedAssets;
|
||||
}
|
||||
|
||||
onEmptyTrash() async {
|
||||
processing.value = true;
|
||||
await ref.read(trashProvider.notifier).emptyTrash();
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Emptied trash',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleEmptyTrash() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () => onEmptyTrash(),
|
||||
title: "trash_page_empty_trash_btn".tr(),
|
||||
ok: "trash_page_empty_trash_dialog_ok".tr(),
|
||||
content: "trash_page_empty_trash_dialog_content".tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onPermanentlyDelete() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(selection.value, force: true);
|
||||
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'${selection.value.length} $assetOrAssets deleted permanently',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
handlePermanentDelete() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteDialog(
|
||||
onDelete: () => onPermanentlyDelete(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleRestoreAll() async {
|
||||
processing.value = true;
|
||||
await ref.read(trashProvider.notifier).restoreTrash();
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
||||
Future<void> handleRestore() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
final result = await ref
|
||||
.read(trashProvider.notifier)
|
||||
.restoreAssets(selection.value);
|
||||
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
if (result && context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'${selection.value.length} $assetOrAssets restored successfully',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
String getAppBarTitle(String count) {
|
||||
if (selectionEnabledHook.value) {
|
||||
return selection.value.isNotEmpty
|
||||
? "${selection.value.length}"
|
||||
: "trash_page_select_assets_btn".tr();
|
||||
}
|
||||
return 'trash_page_title'.tr(args: [count]);
|
||||
}
|
||||
|
||||
AppBar buildAppBar(String count) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: !selectionEnabledHook.value
|
||||
? () => AutoRouter.of(context).pop()
|
||||
: () {
|
||||
selectionEnabledHook.value = false;
|
||||
selection.value = {};
|
||||
},
|
||||
icon: !selectionEnabledHook.value
|
||||
? const Icon(Icons.arrow_back_ios_rounded)
|
||||
: const Icon(Icons.close_rounded),
|
||||
),
|
||||
centerTitle: !selectionEnabledHook.value,
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(getAppBarTitle(count)),
|
||||
actions: <Widget>[
|
||||
if (!selectionEnabledHook.value)
|
||||
PopupMenuButton<void Function()>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: () => selectionEnabledHook.value = true,
|
||||
child: const Text('trash_page_select_btn').tr(),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: handleEmptyTrash,
|
||||
child: const Text('trash_page_empty_trash_btn').tr(),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (fn) => fn(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBottomBar() {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Container(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: Icon(
|
||||
Icons.delete_forever,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
label: Text(
|
||||
selection.value.isEmpty
|
||||
? 'trash_page_delete_all'.tr()
|
||||
: 'trash_page_delete'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.red[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: processing.value
|
||||
? null
|
||||
: selection.value.isEmpty
|
||||
? handleEmptyTrash
|
||||
: handlePermanentDelete,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(
|
||||
Icons.history_rounded,
|
||||
),
|
||||
label: Text(
|
||||
selection.value.isEmpty
|
||||
? 'trash_page_restore_all'.tr()
|
||||
: 'trash_page_restore'.tr(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: processing.value
|
||||
? null
|
||||
: selection.value.isEmpty
|
||||
? handleRestoreAll
|
||||
: handleRestore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return trashedAssets.when(
|
||||
loading: () => Scaffold(
|
||||
appBar: buildAppBar("?"),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stackTrace) => Scaffold(
|
||||
appBar: buildAppBar("!"),
|
||||
body: Center(child: Text(error.toString())),
|
||||
),
|
||||
data: (data) => Scaffold(
|
||||
appBar: buildAppBar(data.totalAssets.toString()),
|
||||
body: data.isEmpty
|
||||
? Center(
|
||||
child: Text('trash_page_no_assets'.tr()),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: ImmichAssetGrid(
|
||||
renderList: data,
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
showMultiSelectIndicator: false,
|
||||
topWidget: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 24,
|
||||
bottom: 24,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
child: const Text(
|
||||
"trash_page_info",
|
||||
).tr(args: ["$trashDays"]),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar(),
|
||||
if (processing.value)
|
||||
const Center(child: ImmichLoadingIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue