mirror of
https://github.com/immich-app/immich.git
synced 2025-01-07 20:36:48 +01:00
b2f2be3485
* refactor: library scanning fix tests remove offline files step cleanup library service improve tests cleanup tests add db migration fix e2e cleanup openapi fix tests fix tests update docs update docs update mobile code fix formatting don't remove assets from library with invalid import path use trash for offline files add migration simplify scan endpoint cleanup library panel fix library tests e2e lint fix e2e trash e2e fix lint add asset trash tests add more tests ensure thumbs are generated cleanup svelte cleanup queue names fix tests fix lint add warning due to trash fix trash tests fix lint fix tests Admin message for offline asset fix comments Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> add permission to library scan endpoint revert asset interface sort add trash reason to shared link stub improve path view in offline update docs improve trash performance fix comments remove stray comment * refactor: add back isOffline and remove trashReason from asset, change sync job flow * chore(server): drop coverage to 80% for functions * chore: rebase and generated files --------- Co-authored-by: Zack Pollard <zackpollard@ymail.com>
350 lines
12 KiB
Dart
350 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:auto_route/auto_route.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
|
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
|
import 'package:immich_mobile/services/stack.service.dart';
|
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
|
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
|
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
|
import 'package:immich_mobile/routing/router.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
import 'package:immich_mobile/providers/user.provider.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
|
|
|
class BottomGalleryBar extends ConsumerWidget {
|
|
final Asset asset;
|
|
final ValueNotifier<int> assetIndex;
|
|
final bool showStack;
|
|
final int stackIndex;
|
|
final ValueNotifier<int> totalAssets;
|
|
final bool showVideoPlayerControls;
|
|
final PageController controller;
|
|
final RenderList renderList;
|
|
|
|
const BottomGalleryBar({
|
|
super.key,
|
|
required this.showStack,
|
|
required this.stackIndex,
|
|
required this.asset,
|
|
required this.assetIndex,
|
|
required this.controller,
|
|
required this.totalAssets,
|
|
required this.showVideoPlayerControls,
|
|
required this.renderList,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
|
|
|
final stackItems = showStack && asset.stackCount > 0
|
|
? ref.watch(assetStackStateProvider(asset))
|
|
: <Asset>[];
|
|
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
|
final navStack = AutoRouter.of(context).stackData;
|
|
final isTrashEnabled =
|
|
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
|
final isFromTrash = isTrashEnabled &&
|
|
navStack.length > 2 &&
|
|
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
|
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
|
|
|
void removeAssetFromStack() {
|
|
if (stackIndex > 0 && showStack) {
|
|
ref
|
|
.read(assetStackStateProvider(asset).notifier)
|
|
.removeChild(stackIndex - 1);
|
|
}
|
|
}
|
|
|
|
void handleDelete() async {
|
|
Future<bool> onDelete(bool force) async {
|
|
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
|
{asset},
|
|
force: force,
|
|
);
|
|
if (isDeleted && isStackPrimaryAsset) {
|
|
// Workaround for asset remaining in the gallery
|
|
renderList.deleteAsset(asset);
|
|
|
|
// `assetIndex == totalAssets.value - 1` handle the case of removing the last asset
|
|
// to not throw the error when the next preCache index is called
|
|
if (totalAssets.value == 1 ||
|
|
assetIndex.value == totalAssets.value - 1) {
|
|
// Handle only one asset
|
|
context.maybePop();
|
|
}
|
|
|
|
totalAssets.value -= 1;
|
|
}
|
|
return isDeleted;
|
|
}
|
|
|
|
// Asset is trashed
|
|
if (isTrashEnabled && !isFromTrash) {
|
|
final isDeleted = await onDelete(false);
|
|
if (isDeleted) {
|
|
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
|
if (context.mounted && asset.isRemote && isStackPrimaryAsset) {
|
|
ImmichToast.show(
|
|
durationInSecond: 1,
|
|
context: context,
|
|
msg: 'Asset trashed',
|
|
gravity: ToastGravity.BOTTOM,
|
|
);
|
|
}
|
|
removeAssetFromStack();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Asset is permanently removed
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext _) {
|
|
return DeleteDialog(
|
|
onDelete: () async {
|
|
final isDeleted = await onDelete(true);
|
|
if (isDeleted) {
|
|
removeAssetFromStack();
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
unStack() async {
|
|
if (asset.stackId == null) {
|
|
return;
|
|
}
|
|
|
|
await ref
|
|
.read(stackServiceProvider)
|
|
.deleteStack(asset.stackId!, [asset, ...stackItems]);
|
|
}
|
|
|
|
void showStackActionItems() {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
enableDrag: false,
|
|
builder: (BuildContext ctx) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 24.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.filter_none_outlined,
|
|
size: 18,
|
|
),
|
|
onTap: () async {
|
|
await unStack();
|
|
ctx.pop();
|
|
context.maybePop();
|
|
},
|
|
title: const Text(
|
|
"viewer_unstack",
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
).tr(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
shareAsset() {
|
|
ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
|
|
}
|
|
|
|
void handleEdit() async {
|
|
final image = Image(image: ImmichImage.imageProvider(asset: asset));
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => EditImagePage(
|
|
asset: asset,
|
|
image: image,
|
|
isEdited: false,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
handleArchive() {
|
|
ref.read(assetProvider.notifier).toggleArchive([asset]);
|
|
if (isStackPrimaryAsset) {
|
|
context.maybePop();
|
|
return;
|
|
}
|
|
removeAssetFromStack();
|
|
}
|
|
|
|
handleDownload() {
|
|
if (asset.isLocal) {
|
|
return;
|
|
}
|
|
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
|
asset,
|
|
context,
|
|
);
|
|
}
|
|
|
|
handleRemoveFromAlbum() async {
|
|
final album = ref.read(currentAlbumProvider);
|
|
final bool isSuccess = album != null &&
|
|
await ref
|
|
.read(sharedAlbumProvider.notifier)
|
|
.removeAssetFromAlbum(album, [asset]);
|
|
|
|
if (isSuccess) {
|
|
// Workaround for asset remaining in the gallery
|
|
renderList.deleteAsset(asset);
|
|
|
|
if (totalAssets.value == 1) {
|
|
// Handle empty viewer
|
|
await context.maybePop();
|
|
} else {
|
|
// changing this also for the last asset causes the parent to rebuild with an error
|
|
totalAssets.value -= 1;
|
|
}
|
|
if (assetIndex.value == totalAssets.value && assetIndex.value > 0) {
|
|
// handle the case of removing the last asset in the list
|
|
assetIndex.value -= 1;
|
|
}
|
|
} else {
|
|
ImmichToast.show(
|
|
context: context,
|
|
msg: "album_viewer_appbar_share_err_remove".tr(),
|
|
toastType: ToastType.error,
|
|
gravity: ToastGravity.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
final List<Map<BottomNavigationBarItem, Function(int)>> albumActions = [
|
|
{
|
|
BottomNavigationBarItem(
|
|
icon: Icon(
|
|
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
|
),
|
|
label: 'control_bottom_app_bar_share'.tr(),
|
|
tooltip: 'control_bottom_app_bar_share'.tr(),
|
|
): (_) => shareAsset(),
|
|
},
|
|
if (asset.isImage)
|
|
{
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.tune_outlined),
|
|
label: 'control_bottom_app_bar_edit'.tr(),
|
|
tooltip: 'control_bottom_app_bar_edit'.tr(),
|
|
): (_) => handleEdit(),
|
|
},
|
|
if (isOwner)
|
|
{
|
|
asset.isArchived
|
|
? BottomNavigationBarItem(
|
|
icon: const Icon(Icons.unarchive_rounded),
|
|
label: 'control_bottom_app_bar_unarchive'.tr(),
|
|
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
|
)
|
|
: BottomNavigationBarItem(
|
|
icon: const Icon(Icons.archive_outlined),
|
|
label: 'control_bottom_app_bar_archive'.tr(),
|
|
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
|
): (_) => handleArchive(),
|
|
},
|
|
if (isOwner && asset.stackCount > 0)
|
|
{
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.burst_mode_outlined),
|
|
label: 'control_bottom_app_bar_stack'.tr(),
|
|
tooltip: 'control_bottom_app_bar_stack'.tr(),
|
|
): (_) => showStackActionItems(),
|
|
},
|
|
if (isOwner && !isInAlbum)
|
|
{
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.delete_outline),
|
|
label: 'control_bottom_app_bar_delete'.tr(),
|
|
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
|
): (_) => handleDelete(),
|
|
},
|
|
if (!isOwner)
|
|
{
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.download_outlined),
|
|
label: 'control_bottom_app_bar_download'.tr(),
|
|
tooltip: 'control_bottom_app_bar_download'.tr(),
|
|
): (_) => handleDownload(),
|
|
},
|
|
if (isInAlbum)
|
|
{
|
|
BottomNavigationBarItem(
|
|
icon: const Icon(Icons.remove_circle_outline),
|
|
label: 'album_viewer_appbar_share_remove'.tr(),
|
|
tooltip: 'album_viewer_appbar_share_remove'.tr(),
|
|
): (_) => handleRemoveFromAlbum(),
|
|
},
|
|
];
|
|
return IgnorePointer(
|
|
ignoring: !ref.watch(showControlsProvider),
|
|
child: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 100),
|
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
|
child: Column(
|
|
children: [
|
|
Visibility(
|
|
visible: showVideoPlayerControls,
|
|
child: const VideoControls(),
|
|
),
|
|
BottomNavigationBar(
|
|
backgroundColor: Colors.black.withOpacity(0.4),
|
|
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
|
selectedIconTheme: const IconThemeData(color: Colors.white),
|
|
unselectedLabelStyle: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w500,
|
|
height: 2.3,
|
|
),
|
|
selectedLabelStyle: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w500,
|
|
height: 2.3,
|
|
),
|
|
unselectedFontSize: 14,
|
|
selectedFontSize: 14,
|
|
selectedItemColor: Colors.white,
|
|
unselectedItemColor: Colors.white,
|
|
showSelectedLabels: true,
|
|
showUnselectedLabels: true,
|
|
items:
|
|
albumActions.map((e) => e.keys.first).toList(growable: false),
|
|
onTap: (index) {
|
|
albumActions[index].values.first.call(index);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|