mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 10:12:33 -04:00 
			
		
		
		
	* feat(mobile): delete assets from device only * mobile: add backed up only toggle for delete device only * remove toggle inside alert and show different content * mobile: change content color for local only * mobile: delete local only button to dialog * style: display bottom action in two lines * feat: separate delete buttons * fix: incorrect error message for ownedRemoteSelection * feat(mobile): long-press delete to permanently delete asset * chore: add todo to handle long press to delete in gallery_viewer * chore: rebase on deletion branch * feat(mobile): long-press delete to permanently delete asset * fix(mobile): update minChildSize of control bottom app bar --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
		
			
				
	
	
		
			942 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			942 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:io';
 | |
| import 'dart:math';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:auto_route/auto_route.dart';
 | |
| 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/extensions/build_context_extensions.dart';
 | |
| import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.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';
 | |
| import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 | |
| import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
 | |
| 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/modules/partner/providers/partner.provider.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/providers/user.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';
 | |
| import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
 | |
| import 'package:immich_mobile/shared/models/asset.dart';
 | |
| import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | |
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 | |
| import 'package:immich_mobile/utils/image_url_builder.dart';
 | |
| import 'package:isar/isar.dart';
 | |
| import 'package:openapi/api.dart' show ThumbnailFormat;
 | |
| 
 | |
| @RoutePage()
 | |
| // ignore: must_be_immutable
 | |
| class GalleryViewerPage extends HookConsumerWidget {
 | |
|   final Asset Function(int index) loadAsset;
 | |
|   final int totalAssets;
 | |
|   final int initialIndex;
 | |
|   final int heroOffset;
 | |
|   final bool showStack;
 | |
| 
 | |
|   GalleryViewerPage({
 | |
|     super.key,
 | |
|     required this.initialIndex,
 | |
|     required this.loadAsset,
 | |
|     required this.totalAssets,
 | |
|     this.heroOffset = 0,
 | |
|     this.showStack = false,
 | |
|   }) : controller = PageController(initialPage: initialIndex);
 | |
| 
 | |
|   final PageController controller;
 | |
| 
 | |
|   static const jpeg = ThumbnailFormat.JPEG;
 | |
|   static const webp = ThumbnailFormat.WEBP;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final settings = ref.watch(appSettingsServiceProvider);
 | |
|     final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
 | |
|     final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
 | |
|     final isZoomed = useState<bool>(false);
 | |
|     final isPlayingMotionVideo = useState(false);
 | |
|     final isPlayingVideo = useState(false);
 | |
|     Offset? localPosition;
 | |
|     final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
 | |
|     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;
 | |
|     final stackIndex = useState(-1);
 | |
|     final stack = showStack && currentAsset.stackChildrenCount > 0
 | |
|         ? ref.watch(assetStackStateProvider(currentAsset))
 | |
|         : <Asset>[];
 | |
|     final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
 | |
|     // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
 | |
|     final isFromDto = currentAsset.id == Isar.autoIncrement;
 | |
|     final album = ref.watch(currentAlbumProvider);
 | |
| 
 | |
|     Asset asset() => stackIndex.value == -1
 | |
|         ? currentAsset
 | |
|         : stackElements.elementAt(stackIndex.value);
 | |
|     final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId;
 | |
|     final isPartner = ref
 | |
|         .watch(partnerSharedWithProvider)
 | |
|         .map((e) => e.isarId)
 | |
|         .contains(asset().ownerId);
 | |
| 
 | |
|     bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
 | |
| 
 | |
|     // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
 | |
|     ref.listen(currentAssetProvider, (_, __) {});
 | |
|     useEffect(
 | |
|       () {
 | |
|         // Delay state update to after the execution of build method
 | |
|         Future.microtask(
 | |
|           () => ref.read(currentAssetProvider.notifier).set(asset()),
 | |
|         );
 | |
|         return null;
 | |
|       },
 | |
|       [asset()],
 | |
|     );
 | |
| 
 | |
|     useEffect(
 | |
|       () {
 | |
|         isLoadPreview.value =
 | |
|             settings.getSetting<bool>(AppSettingsEnum.loadPreview);
 | |
|         isLoadOriginal.value =
 | |
|             settings.getSetting<bool>(AppSettingsEnum.loadOriginal);
 | |
|         isPlayingMotionVideo.value = false;
 | |
|         return null;
 | |
|       },
 | |
|       [],
 | |
|     );
 | |
| 
 | |
|     void toggleFavorite(Asset asset) =>
 | |
|         ref.read(assetProvider.notifier).toggleFavorite([asset]);
 | |
| 
 | |
|     /// Original (large) image of a remote asset. Required asset.isRemote
 | |
|     ImageProvider remoteOriginalProvider(Asset asset) =>
 | |
|         CachedNetworkImageProvider(
 | |
|           getImageUrl(asset),
 | |
|           cacheKey: getImageCacheKey(asset),
 | |
|           headers: header,
 | |
|         );
 | |
| 
 | |
|     /// Original (large) image of a local asset. Required asset.isLocal
 | |
|     ImageProvider localOriginalProvider(Asset asset) =>
 | |
|         OriginalImageProvider(asset);
 | |
| 
 | |
|     ImageProvider finalImageProvider(Asset asset) {
 | |
|       if (ImmichImage.useLocal(asset)) {
 | |
|         return localOriginalProvider(asset);
 | |
|       } else if (isLoadOriginal.value) {
 | |
|         return remoteOriginalProvider(asset);
 | |
|       } else if (isLoadPreview.value) {
 | |
|         return ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
 | |
|       }
 | |
|       return ImmichImage.remoteThumbnailProvider(asset, webp, header);
 | |
|     }
 | |
| 
 | |
|     Iterable<ImageProvider> allImageProviders(Asset asset) sync* {
 | |
|       if (ImmichImage.useLocal(asset)) {
 | |
|         yield ImmichImage.localThumbnailProvider(asset);
 | |
|         yield localOriginalProvider(asset);
 | |
|       } else {
 | |
|         yield ImmichImage.remoteThumbnailProvider(asset, webp, header);
 | |
|         if (isLoadPreview.value) {
 | |
|           yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
 | |
|         }
 | |
|         if (isLoadOriginal.value) {
 | |
|           yield remoteOriginalProvider(asset);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void precacheNextImage(int index) {
 | |
|       void onError(Object exception, StackTrace? stackTrace) {
 | |
|         // swallow error silently
 | |
|       }
 | |
|       if (index < totalAssets && index >= 0) {
 | |
|         final asset = loadAsset(index);
 | |
|         for (final imageProvider in allImageProviders(asset)) {
 | |
|           precacheImage(imageProvider, context, onError: onError);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void showInfo() {
 | |
|       showModalBottomSheet(
 | |
|         shape: const RoundedRectangleBorder(
 | |
|           borderRadius: BorderRadius.all(Radius.circular(15.0)),
 | |
|         ),
 | |
|         barrierColor: Colors.transparent,
 | |
|         backgroundColor: Colors.transparent,
 | |
|         isScrollControlled: true,
 | |
|         useSafeArea: true,
 | |
|         context: context,
 | |
|         builder: (context) {
 | |
|           return Padding(
 | |
|             padding: EdgeInsets.only(
 | |
|               bottom: MediaQuery.viewInsetsOf(context).bottom,
 | |
|             ),
 | |
|             child: ref
 | |
|                     .watch(appSettingsServiceProvider)
 | |
|                     .getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
 | |
|                 ? AdvancedBottomSheet(assetDetail: asset())
 | |
|                 : ExifBottomSheet(asset: asset()),
 | |
|           );
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     void removeAssetFromStack() {
 | |
|       if (stackIndex.value > 0 && showStack) {
 | |
|         ref
 | |
|             .read(assetStackStateProvider(currentAsset).notifier)
 | |
|             .removeChild(stackIndex.value - 1);
 | |
|         stackIndex.value = stackIndex.value - 1;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void handleDelete(Asset deleteAsset) async {
 | |
|       // Cannot delete readOnly / external assets. They are handled through library offline jobs
 | |
|       if (asset().isReadOnly) {
 | |
|         ImmichToast.show(
 | |
|           durationInSecond: 1,
 | |
|           context: context,
 | |
|           msg: 'asset_action_delete_err_read_only'.tr(),
 | |
|           gravity: ToastGravity.BOTTOM,
 | |
|         );
 | |
|         return;
 | |
|       }
 | |
|       Future<bool> onDelete(bool force) async {
 | |
|         final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
 | |
|           {deleteAsset},
 | |
|           force: force,
 | |
|         );
 | |
|         if (isDeleted && isParent) {
 | |
|           if (totalAssets == 1) {
 | |
|             // Handle only one asset
 | |
|             context.popRoute();
 | |
|           } 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);
 | |
|         if (isDeleted) {
 | |
|           // Can only trash assets stored in server. Local assets are always permanently removed for now
 | |
|           if (context.mounted && deleteAsset.isRemote && isParent) {
 | |
|             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();
 | |
|               }
 | |
|             },
 | |
|           );
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     void addToAlbum(Asset addToAlbumAsset) {
 | |
|       showModalBottomSheet(
 | |
|         elevation: 0,
 | |
|         shape: RoundedRectangleBorder(
 | |
|           borderRadius: BorderRadius.circular(15.0),
 | |
|         ),
 | |
|         context: context,
 | |
|         builder: (BuildContext _) {
 | |
|           return AddToAlbumBottomSheet(
 | |
|             assets: [addToAlbumAsset],
 | |
|           );
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     void handleSwipeUpDown(DragUpdateDetails details) {
 | |
|       int sensitivity = 15;
 | |
|       int dxThreshold = 50;
 | |
|       double ratioThreshold = 3.0;
 | |
| 
 | |
|       if (isZoomed.value) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Guard [localPosition] null
 | |
|       if (localPosition == null) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Check for delta from initial down point
 | |
|       final d = details.localPosition - localPosition!;
 | |
|       // If the magnitude of the dx swipe is large, we probably didn't mean to go down
 | |
|       if (d.dx.abs() > dxThreshold) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       final ratio = d.dy / max(d.dx.abs(), 1);
 | |
|       if (d.dy > sensitivity && ratio > ratioThreshold) {
 | |
|         context.popRoute();
 | |
|       } else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
 | |
|         showInfo();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     shareAsset() {
 | |
|       if (asset().isOffline) {
 | |
|         ImmichToast.show(
 | |
|           durationInSecond: 1,
 | |
|           context: context,
 | |
|           msg: 'asset_action_share_err_offline'.tr(),
 | |
|           gravity: ToastGravity.BOTTOM,
 | |
|         );
 | |
|         return;
 | |
|       }
 | |
|       ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context);
 | |
|     }
 | |
| 
 | |
|     handleArchive(Asset asset) {
 | |
|       ref.read(assetProvider.notifier).toggleArchive([asset]);
 | |
|       if (isParent) {
 | |
|         context.popRoute();
 | |
|         return;
 | |
|       }
 | |
|       removeAssetFromStack();
 | |
|     }
 | |
| 
 | |
|     handleUpload(Asset asset) {
 | |
|       showDialog(
 | |
|         context: context,
 | |
|         builder: (BuildContext _) {
 | |
|           return UploadDialog(
 | |
|             onUpload: () {
 | |
|               ref
 | |
|                   .read(manualUploadProvider.notifier)
 | |
|                   .uploadAssets(context, [asset]);
 | |
|             },
 | |
|           );
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     handleDownload() {
 | |
|       if (asset().isLocal) {
 | |
|         return;
 | |
|       }
 | |
|       if (asset().isOffline) {
 | |
|         ImmichToast.show(
 | |
|           durationInSecond: 1,
 | |
|           context: context,
 | |
|           msg: 'asset_action_share_err_offline'.tr(),
 | |
|           gravity: ToastGravity.BOTTOM,
 | |
|         );
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       ref.read(imageViewerStateProvider.notifier).downloadAsset(
 | |
|             asset(),
 | |
|             context,
 | |
|           );
 | |
|     }
 | |
| 
 | |
|     handleActivities() {
 | |
|       if (album != null && album.shared && album.remoteId != null) {
 | |
|         context.pushRoute(const ActivitiesRoute());
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     buildAppBar() {
 | |
|       return IgnorePointer(
 | |
|         ignoring: !ref.watch(showControlsProvider),
 | |
|         child: AnimatedOpacity(
 | |
|           duration: const Duration(milliseconds: 100),
 | |
|           opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
 | |
|           child: Container(
 | |
|             color: Colors.black.withOpacity(0.4),
 | |
|             child: TopControlAppBar(
 | |
|               isOwner: isOwner,
 | |
|               isPartner: isPartner,
 | |
|               isPlayingMotionVideo: isPlayingMotionVideo.value,
 | |
|               asset: asset(),
 | |
|               onMoreInfoPressed: showInfo,
 | |
|               onFavorite: toggleFavorite,
 | |
|               onUploadPressed:
 | |
|                   asset().isLocal ? () => handleUpload(asset()) : null,
 | |
|               onDownloadPressed: asset().isLocal
 | |
|                   ? null
 | |
|                   : () =>
 | |
|                       ref.read(imageViewerStateProvider.notifier).downloadAsset(
 | |
|                             asset(),
 | |
|                             context,
 | |
|                           ),
 | |
|               onToggleMotionVideo: (() {
 | |
|                 isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
 | |
|               }),
 | |
|               onAddToAlbumPressed: () => addToAlbum(asset()),
 | |
|               onActivitiesPressed: handleActivities,
 | |
|             ),
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Widget buildProgressBar() {
 | |
|       final playerValue = ref.watch(videoPlaybackValueProvider);
 | |
| 
 | |
|       return Expanded(
 | |
|         child: Slider(
 | |
|           value: playerValue.duration == Duration.zero
 | |
|               ? 0.0
 | |
|               : min(
 | |
|                   playerValue.position.inMicroseconds /
 | |
|                       playerValue.duration.inMicroseconds *
 | |
|                       100,
 | |
|                   100,
 | |
|                 ),
 | |
|           min: 0,
 | |
|           max: 100,
 | |
|           thumbColor: Colors.white,
 | |
|           activeColor: Colors.white,
 | |
|           inactiveColor: Colors.white.withOpacity(0.75),
 | |
|           onChanged: (position) {
 | |
|             ref.read(videoPlayerControlsProvider.notifier).position = position;
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Text buildPosition() {
 | |
|       final position = ref
 | |
|           .watch(videoPlaybackValueProvider.select((value) => value.position));
 | |
| 
 | |
|       return Text(
 | |
|         _formatDuration(position),
 | |
|         style: TextStyle(
 | |
|           fontSize: 14.0,
 | |
|           color: Colors.white.withOpacity(.75),
 | |
|           fontWeight: FontWeight.normal,
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Text buildDuration() {
 | |
|       final duration = ref
 | |
|           .watch(videoPlaybackValueProvider.select((value) => value.duration));
 | |
| 
 | |
|       return Text(
 | |
|         _formatDuration(duration),
 | |
|         style: TextStyle(
 | |
|           fontSize: 14.0,
 | |
|           color: Colors.white.withOpacity(.75),
 | |
|           fontWeight: FontWeight.normal,
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Widget buildMuteButton() {
 | |
|       return IconButton(
 | |
|         icon: Icon(
 | |
|           ref.watch(videoPlayerControlsProvider.select((value) => value.mute))
 | |
|               ? Icons.volume_off
 | |
|               : Icons.volume_up,
 | |
|         ),
 | |
|         onPressed: () =>
 | |
|             ref.read(videoPlayerControlsProvider.notifier).toggleMute(),
 | |
|         color: Colors.white,
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Widget buildStackedChildren() {
 | |
|       return ListView.builder(
 | |
|         shrinkWrap: true,
 | |
|         scrollDirection: Axis.horizontal,
 | |
|         itemCount: stackElements.length,
 | |
|         itemBuilder: (context, index) {
 | |
|           final assetId = stackElements.elementAt(index).remoteId;
 | |
|           return Padding(
 | |
|             padding: const EdgeInsets.only(right: 10),
 | |
|             child: GestureDetector(
 | |
|               onTap: () => stackIndex.value = index,
 | |
|               child: Container(
 | |
|                 width: 40,
 | |
|                 decoration: BoxDecoration(
 | |
|                   color: Colors.white,
 | |
|                   borderRadius: BorderRadius.circular(6),
 | |
|                   border: (stackIndex.value == -1 && index == 0) ||
 | |
|                           index == stackIndex.value
 | |
|                       ? Border.all(
 | |
|                           color: Colors.white,
 | |
|                           width: 2,
 | |
|                         )
 | |
|                       : null,
 | |
|                 ),
 | |
|                 child: ClipRRect(
 | |
|                   borderRadius: BorderRadius.circular(4),
 | |
|                   child: CachedNetworkImage(
 | |
|                     fit: BoxFit.cover,
 | |
|                     imageUrl:
 | |
|                         '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
 | |
|                     httpHeaders: {
 | |
|                       "Authorization":
 | |
|                           "Bearer ${Store.get(StoreKey.accessToken)}",
 | |
|                     },
 | |
|                     errorWidget: (context, url, error) =>
 | |
|                         const Icon(Icons.image_not_supported_outlined),
 | |
|                   ),
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           );
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     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: [
 | |
|                   if (!isParent)
 | |
|                     ListTile(
 | |
|                       leading: const Icon(
 | |
|                         Icons.bookmark_border_outlined,
 | |
|                         size: 24,
 | |
|                       ),
 | |
|                       onTap: () async {
 | |
|                         await ref
 | |
|                             .read(assetStackServiceProvider)
 | |
|                             .updateStackParent(
 | |
|                               currentAsset,
 | |
|                               stackElements.elementAt(stackIndex.value),
 | |
|                             );
 | |
|                         ctx.pop();
 | |
|                         context.popRoute();
 | |
|                       },
 | |
|                       title: const Text(
 | |
|                         "viewer_stack_use_as_main_asset",
 | |
|                         style: TextStyle(fontWeight: FontWeight.bold),
 | |
|                       ).tr(),
 | |
|                     ),
 | |
|                   ListTile(
 | |
|                     leading: const Icon(
 | |
|                       Icons.copy_all_outlined,
 | |
|                       size: 24,
 | |
|                     ),
 | |
|                     onTap: () async {
 | |
|                       if (isParent) {
 | |
|                         await ref
 | |
|                             .read(assetStackServiceProvider)
 | |
|                             .updateStackParent(
 | |
|                               currentAsset,
 | |
|                               stackElements
 | |
|                                   .elementAt(1), // Next asset as parent
 | |
|                             );
 | |
|                         // Remove itself from stack
 | |
|                         await ref.read(assetStackServiceProvider).updateStack(
 | |
|                           stackElements.elementAt(1),
 | |
|                           childrenToRemove: [currentAsset],
 | |
|                         );
 | |
|                         ctx.pop();
 | |
|                         context.popRoute();
 | |
|                       } else {
 | |
|                         await ref.read(assetStackServiceProvider).updateStack(
 | |
|                           currentAsset,
 | |
|                           childrenToRemove: [
 | |
|                             stackElements.elementAt(stackIndex.value),
 | |
|                           ],
 | |
|                         );
 | |
|                         removeAssetFromStack();
 | |
|                         ctx.pop();
 | |
|                       }
 | |
|                     },
 | |
|                     title: const Text(
 | |
|                       "viewer_remove_from_stack",
 | |
|                       style: TextStyle(fontWeight: FontWeight.bold),
 | |
|                     ).tr(),
 | |
|                   ),
 | |
|                   ListTile(
 | |
|                     leading: const Icon(
 | |
|                       Icons.filter_none_outlined,
 | |
|                       size: 18,
 | |
|                     ),
 | |
|                     onTap: () async {
 | |
|                       await ref.read(assetStackServiceProvider).updateStack(
 | |
|                             currentAsset,
 | |
|                             childrenToRemove: stack,
 | |
|                           );
 | |
|                       ctx.pop();
 | |
|                       context.popRoute();
 | |
|                     },
 | |
|                     title: const Text(
 | |
|                       "viewer_unstack",
 | |
|                       style: TextStyle(fontWeight: FontWeight.bold),
 | |
|                     ).tr(),
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           );
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     // TODO: Migrate to a custom bottom bar and handle long press to delete
 | |
|     Widget buildBottomBar() {
 | |
|       // !!!! itemsList and actionlist should always be in sync
 | |
|       final itemsList = [
 | |
|         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(),
 | |
|         ),
 | |
|         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(),
 | |
|                 ),
 | |
|         if (isOwner && stack.isNotEmpty)
 | |
|           BottomNavigationBarItem(
 | |
|             icon: const Icon(Icons.burst_mode_outlined),
 | |
|             label: 'control_bottom_app_bar_stack'.tr(),
 | |
|             tooltip: 'control_bottom_app_bar_stack'.tr(),
 | |
|           ),
 | |
|         if (isOwner)
 | |
|           BottomNavigationBarItem(
 | |
|             icon: const Icon(Icons.delete_outline),
 | |
|             label: 'control_bottom_app_bar_delete'.tr(),
 | |
|             tooltip: 'control_bottom_app_bar_delete'.tr(),
 | |
|           ),
 | |
|         if (!isOwner)
 | |
|           BottomNavigationBarItem(
 | |
|             icon: const Icon(Icons.download_outlined),
 | |
|             label: 'download'.tr(),
 | |
|             tooltip: 'download'.tr(),
 | |
|           ),
 | |
|       ];
 | |
| 
 | |
|       List<Function(int)> actionslist = [
 | |
|         (_) => shareAsset(),
 | |
|         if (isOwner) (_) => handleArchive(asset()),
 | |
|         if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
 | |
|         if (isOwner) (_) => handleDelete(asset()),
 | |
|         if (!isOwner) (_) => handleDownload(),
 | |
|       ];
 | |
| 
 | |
|       return IgnorePointer(
 | |
|         ignoring: !ref.watch(showControlsProvider),
 | |
|         child: AnimatedOpacity(
 | |
|           duration: const Duration(milliseconds: 100),
 | |
|           opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
 | |
|           child: Column(
 | |
|             children: [
 | |
|               if (stack.isNotEmpty)
 | |
|                 Padding(
 | |
|                   padding: const EdgeInsets.only(
 | |
|                     left: 10,
 | |
|                     bottom: 30,
 | |
|                   ),
 | |
|                   child: SizedBox(
 | |
|                     height: 40,
 | |
|                     child: buildStackedChildren(),
 | |
|                   ),
 | |
|                 ),
 | |
|               Visibility(
 | |
|                 visible: !asset().isImage && !isPlayingMotionVideo.value,
 | |
|                 child: Container(
 | |
|                   color: Colors.black.withOpacity(0.4),
 | |
|                   child: Padding(
 | |
|                     padding: MediaQuery.of(context).orientation ==
 | |
|                             Orientation.portrait
 | |
|                         ? const EdgeInsets.symmetric(horizontal: 12.0)
 | |
|                         : const EdgeInsets.symmetric(horizontal: 64.0),
 | |
|                     child: Row(
 | |
|                       children: [
 | |
|                         buildPosition(),
 | |
|                         buildProgressBar(),
 | |
|                         buildDuration(),
 | |
|                         buildMuteButton(),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ),
 | |
|               BottomNavigationBar(
 | |
|                 backgroundColor: Colors.black.withOpacity(0.4),
 | |
|                 unselectedIconTheme: const IconThemeData(color: Colors.white),
 | |
|                 selectedIconTheme: const IconThemeData(color: Colors.white),
 | |
|                 unselectedLabelStyle: const TextStyle(color: Colors.black),
 | |
|                 selectedLabelStyle: const TextStyle(color: Colors.black),
 | |
|                 showSelectedLabels: false,
 | |
|                 showUnselectedLabels: false,
 | |
|                 items: itemsList,
 | |
|                 onTap: (index) {
 | |
|                   if (index < actionslist.length) {
 | |
|                     actionslist[index].call(index);
 | |
|                   }
 | |
|                 },
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     ref.listen(showControlsProvider, (_, show) {
 | |
|       if (show) {
 | |
|         SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | |
|       } else {
 | |
|         SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     return Scaffold(
 | |
|       backgroundColor: Colors.black,
 | |
|       body: WillPopScope(
 | |
|         onWillPop: () async {
 | |
|           // Change immersive mode back to normal "edgeToEdge" mode
 | |
|           await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
 | |
|           return true;
 | |
|         },
 | |
|         child: Stack(
 | |
|           children: [
 | |
|             PhotoViewGallery.builder(
 | |
|               scaleStateChangedCallback: (state) {
 | |
|                 isZoomed.value = state != PhotoViewScaleState.initial;
 | |
|                 ref.read(showControlsProvider.notifier).show = !isZoomed.value;
 | |
|               },
 | |
|               pageController: controller,
 | |
|               scrollPhysics: isZoomed.value
 | |
|                   ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
 | |
|                   : (Platform.isIOS
 | |
|                       ? const ScrollPhysics() // Use bouncing physics for iOS
 | |
|                       : const ClampingScrollPhysics() // Use heavy physics for Android
 | |
|                   ),
 | |
|               itemCount: totalAssets,
 | |
|               scrollDirection: Axis.horizontal,
 | |
|               onPageChanged: (value) {
 | |
|                 final next = currentIndex.value < value ? value + 1 : value - 1;
 | |
|                 precacheNextImage(next);
 | |
|                 currentIndex.value = value;
 | |
|                 stackIndex.value = -1;
 | |
|                 HapticFeedback.selectionClick();
 | |
|               },
 | |
|               loadingBuilder: (context, event, index) {
 | |
|                 final a = loadAsset(index);
 | |
|                 if (ImmichImage.useLocal(a)) {
 | |
|                   return Image(
 | |
|                     image: ImmichImage.localThumbnailProvider(a),
 | |
|                     fit: BoxFit.contain,
 | |
|                   );
 | |
|                 }
 | |
|                 // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
 | |
|                 // Three-Stage Loading (WEBP -> JPEG -> Original)
 | |
|                 final webPThumbnail = CachedNetworkImage(
 | |
|                   imageUrl: getThumbnailUrl(a, type: webp),
 | |
|                   cacheKey: getThumbnailCacheKey(a, type: webp),
 | |
|                   httpHeaders: header,
 | |
|                   progressIndicatorBuilder: (_, __, ___) => const Center(
 | |
|                     child: ImmichLoadingIndicator(),
 | |
|                   ),
 | |
|                   fadeInDuration: const Duration(milliseconds: 0),
 | |
|                   fit: BoxFit.contain,
 | |
|                   errorWidget: (context, url, error) =>
 | |
|                       const Icon(Icons.image_not_supported_outlined),
 | |
|                 );
 | |
| 
 | |
|                 // loading the preview in the loadingBuilder only
 | |
|                 // makes sense if the original is loaded in the builder
 | |
|                 return isLoadPreview.value && isLoadOriginal.value
 | |
|                     ? CachedNetworkImage(
 | |
|                         imageUrl: getThumbnailUrl(a, type: jpeg),
 | |
|                         cacheKey: getThumbnailCacheKey(a, type: jpeg),
 | |
|                         httpHeaders: header,
 | |
|                         fit: BoxFit.contain,
 | |
|                         fadeInDuration: const Duration(milliseconds: 0),
 | |
|                         placeholder: (_, __) => webPThumbnail,
 | |
|                         errorWidget: (_, __, ___) => webPThumbnail,
 | |
|                       )
 | |
|                     : webPThumbnail;
 | |
|               },
 | |
|               builder: (context, index) {
 | |
|                 final a =
 | |
|                     index == currentIndex.value ? asset() : loadAsset(index);
 | |
|                 final ImageProvider provider = finalImageProvider(a);
 | |
| 
 | |
|                 if (a.isImage && !isPlayingMotionVideo.value) {
 | |
|                   return PhotoViewGalleryPageOptions(
 | |
|                     onDragStart: (_, details, __) =>
 | |
|                         localPosition = details.localPosition,
 | |
|                     onDragUpdate: (_, details, __) =>
 | |
|                         handleSwipeUpDown(details),
 | |
|                     onTapDown: (_, __, ___) {
 | |
|                       ref.read(showControlsProvider.notifier).toggle();
 | |
|                     },
 | |
|                     imageProvider: provider,
 | |
|                     heroAttributes: PhotoViewHeroAttributes(
 | |
|                       tag: isFromDto
 | |
|                           ? '${currentAsset.remoteId}-$heroOffset'
 | |
|                           : currentAsset.id + heroOffset,
 | |
|                       transitionOnUserGestures: true,
 | |
|                     ),
 | |
|                     filterQuality: FilterQuality.high,
 | |
|                     tightMode: true,
 | |
|                     minScale: PhotoViewComputedScale.contained,
 | |
|                     errorBuilder: (context, error, stackTrace) => ImmichImage(
 | |
|                       a,
 | |
|                       fit: BoxFit.contain,
 | |
|                     ),
 | |
|                   );
 | |
|                 } else {
 | |
|                   return PhotoViewGalleryPageOptions.customChild(
 | |
|                     onDragStart: (_, details, __) =>
 | |
|                         localPosition = details.localPosition,
 | |
|                     onDragUpdate: (_, details, __) =>
 | |
|                         handleSwipeUpDown(details),
 | |
|                     heroAttributes: PhotoViewHeroAttributes(
 | |
|                       tag: isFromDto
 | |
|                           ? '${currentAsset.remoteId}-$heroOffset'
 | |
|                           : currentAsset.id + heroOffset,
 | |
|                     ),
 | |
|                     filterQuality: FilterQuality.high,
 | |
|                     maxScale: 1.0,
 | |
|                     minScale: 1.0,
 | |
|                     basePosition: Alignment.center,
 | |
|                     child: VideoViewerPage(
 | |
|                       onPlaying: () => isPlayingVideo.value = true,
 | |
|                       onPaused: () =>
 | |
|                           WidgetsBinding.instance.addPostFrameCallback(
 | |
|                         (_) => isPlayingVideo.value = false,
 | |
|                       ),
 | |
|                       asset: a,
 | |
|                       isMotionVideo: isPlayingMotionVideo.value,
 | |
|                       placeholder: Image(
 | |
|                         image: provider,
 | |
|                         fit: BoxFit.fitWidth,
 | |
|                         height: context.height,
 | |
|                         width: context.width,
 | |
|                         alignment: Alignment.center,
 | |
|                       ),
 | |
|                       onVideoEnded: () {
 | |
|                         if (isPlayingMotionVideo.value) {
 | |
|                           isPlayingMotionVideo.value = false;
 | |
|                         }
 | |
|                       },
 | |
|                     ),
 | |
|                   );
 | |
|                 }
 | |
|               },
 | |
|             ),
 | |
|             Positioned(
 | |
|               top: 0,
 | |
|               left: 0,
 | |
|               right: 0,
 | |
|               child: buildAppBar(),
 | |
|             ),
 | |
|             Positioned(
 | |
|               bottom: 0,
 | |
|               left: 0,
 | |
|               right: 0,
 | |
|               child: buildBottomBar(),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   String _formatDuration(Duration position) {
 | |
|     final ms = position.inMilliseconds;
 | |
| 
 | |
|     int seconds = ms ~/ 1000;
 | |
|     final int hours = seconds ~/ 3600;
 | |
|     seconds = seconds % 3600;
 | |
|     final minutes = seconds ~/ 60;
 | |
|     seconds = seconds % 60;
 | |
| 
 | |
|     final hoursString = hours >= 10
 | |
|         ? '$hours'
 | |
|         : hours == 0
 | |
|             ? '00'
 | |
|             : '0$hours';
 | |
| 
 | |
|     final minutesString = minutes >= 10
 | |
|         ? '$minutes'
 | |
|         : minutes == 0
 | |
|             ? '00'
 | |
|             : '0$minutes';
 | |
| 
 | |
|     final secondsString = seconds >= 10
 | |
|         ? '$seconds'
 | |
|         : seconds == 0
 | |
|             ? '00'
 | |
|             : '0$seconds';
 | |
| 
 | |
|     final formattedTime =
 | |
|         '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
 | |
| 
 | |
|     return formattedTime;
 | |
|   }
 | |
| }
 |