fix(mobile): asset state remain in gallery view after being deleted (#10603)

* fix(mobile): asset doesn't get removed from state renderList

* fix delete last assets

* refactor
This commit is contained in:
Alex 2024-06-26 21:15:26 -07:00 committed by GitHub
parent 922430da36
commit d8175d8da8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 158 additions and 140 deletions

View File

@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart'; import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart'; import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart';
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart'; import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart';
@ -34,17 +35,15 @@ import 'package:isar/isar.dart';
@RoutePage() @RoutePage()
// ignore: must_be_immutable // ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget { class GalleryViewerPage extends HookConsumerWidget {
final Asset Function(int index) loadAsset;
final int totalAssets;
final int initialIndex; final int initialIndex;
final int heroOffset; final int heroOffset;
final bool showStack; final bool showStack;
final RenderList renderList;
GalleryViewerPage({ GalleryViewerPage({
super.key, super.key,
required this.initialIndex, required this.renderList,
required this.loadAsset, this.initialIndex = 0,
required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false, this.showStack = false,
}) : controller = PageController(initialPage: initialIndex); }) : controller = PageController(initialPage: initialIndex);
@ -54,6 +53,8 @@ class GalleryViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider); final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets);
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue); final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
@ -62,6 +63,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final localPosition = useState<Offset?>(null); final localPosition = useState<Offset?>(null);
final currentIndex = useState(initialIndex); final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value); final currentAsset = loadAsset(currentIndex.value);
// Update is playing motion video // Update is playing motion video
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing; isPlayingVideo.value = state == VideoPlaybackState.playing;
@ -112,13 +114,19 @@ class GalleryViewerPage extends HookConsumerWidget {
debugPrint('Error precaching next image: $exception, $stackTrace'); debugPrint('Error precaching next image: $exception, $stackTrace');
} }
if (index < totalAssets && index >= 0) { try {
final asset = loadAsset(index); if (index < totalAssets.value && index >= 0) {
await precacheImage( final asset = loadAsset(index);
ImmichImage.imageProvider(asset: asset), await precacheImage(
context, ImmichImage.imageProvider(asset: asset),
onError: onError, context,
); onError: onError,
);
}
} catch (e) {
// swallow error silently
debugPrint('Error precaching next image: $e');
context.maybePop();
} }
} }
@ -297,7 +305,7 @@ class GalleryViewerPage extends HookConsumerWidget {
? const ScrollPhysics() // Use bouncing physics for iOS ? const ScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android : const ClampingScrollPhysics() // Use heavy physics for Android
), ),
itemCount: totalAssets, itemCount: totalAssets.value,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
onPageChanged: (value) async { onPageChanged: (value) async {
final next = currentIndex.value < value ? value + 1 : value - 1; final next = currentIndex.value < value ? value + 1 : value - 1;
@ -406,11 +414,13 @@ class GalleryViewerPage extends HookConsumerWidget {
), ),
), ),
BottomGalleryBar( BottomGalleryBar(
renderList: renderList,
totalAssets: totalAssets, totalAssets: totalAssets,
controller: controller, controller: controller,
showStack: showStack, showStack: showStack,
stackIndex: stackIndex.value, stackIndex: stackIndex.value,
asset: asset, asset: asset,
assetIndex: currentIndex.value,
showVideoPlayerControls: !asset.isImage && !isMotionPhoto, showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
), ),
], ],

View File

@ -16,6 +16,7 @@ import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
@ -178,12 +179,17 @@ class MapPage extends HookConsumerWidget {
return; return;
} }
// Since we only have a single asset, we can just show GroupAssetBy.none
final renderList = await RenderList.fromAssets(
[asset],
GroupAssetsBy.none,
);
context.pushRoute( context.pushRoute(
GalleryViewerRoute( GalleryViewerRoute(
initialIndex: 0, initialIndex: 0,
loadAsset: (index) => asset,
totalAssets: 1,
heroOffset: 0, heroOffset: 0,
renderList: renderList,
), ),
); );
} }

View File

@ -59,6 +59,7 @@ import 'package:immich_mobile/routing/backup_permission_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart'; import 'package:immich_mobile/routing/custom_transition_builders.dart';
import 'package:immich_mobile/routing/duplicate_guard.dart'; import 'package:immich_mobile/routing/duplicate_guard.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng; import 'package:photo_manager/photo_manager.dart' hide LatLng;

View File

@ -183,9 +183,8 @@ abstract class _$AppRouter extends RootStackRouter {
routeData: routeData, routeData: routeData,
child: GalleryViewerPage( child: GalleryViewerPage(
key: args.key, key: args.key,
renderList: args.renderList,
initialIndex: args.initialIndex, initialIndex: args.initialIndex,
loadAsset: args.loadAsset,
totalAssets: args.totalAssets,
heroOffset: args.heroOffset, heroOffset: args.heroOffset,
showStack: args.showStack, showStack: args.showStack,
), ),
@ -870,9 +869,8 @@ class FavoritesRoute extends PageRouteInfo<void> {
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute({ GalleryViewerRoute({
Key? key, Key? key,
required int initialIndex, required RenderList renderList,
required Asset Function(int) loadAsset, int initialIndex = 0,
required int totalAssets,
int heroOffset = 0, int heroOffset = 0,
bool showStack = false, bool showStack = false,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
@ -880,9 +878,8 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute.name, GalleryViewerRoute.name,
args: GalleryViewerRouteArgs( args: GalleryViewerRouteArgs(
key: key, key: key,
renderList: renderList,
initialIndex: initialIndex, initialIndex: initialIndex,
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack, showStack: showStack,
), ),
@ -898,28 +895,25 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
class GalleryViewerRouteArgs { class GalleryViewerRouteArgs {
const GalleryViewerRouteArgs({ const GalleryViewerRouteArgs({
this.key, this.key,
required this.initialIndex, required this.renderList,
required this.loadAsset, this.initialIndex = 0,
required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false, this.showStack = false,
}); });
final Key? key; final Key? key;
final RenderList renderList;
final int initialIndex; final int initialIndex;
final Asset Function(int) loadAsset;
final int totalAssets;
final int heroOffset; final int heroOffset;
final bool showStack; final bool showStack;
@override @override
String toString() { String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}'; return 'GalleryViewerRouteArgs{key: $key, renderList: $renderList, initialIndex: $initialIndex, heroOffset: $heroOffset, showStack: $showStack}';
} }
} }

View File

@ -311,4 +311,12 @@ class RenderList {
GroupAssetsBy groupBy, GroupAssetsBy groupBy,
) => ) =>
_buildRenderList(assets, null, groupBy); _buildRenderList(assets, null, groupBy);
/// Deletes an asset from the render list and clears the buffer
/// This is only a workaround for deleted images still appearing in the gallery
void deleteAsset(Asset deleteAsset) {
allAssets?.remove(deleteAsset);
_buf.clear();
_bufOffset = 0;
}
} }

View File

@ -2,10 +2,12 @@ import 'dart:collection';
import 'dart:developer'; import 'dart:developer';
import 'dart:math'; import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
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:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
@ -815,6 +817,7 @@ class _AssetRow extends StatelessWidget {
key: key, key: key,
children: assets.mapIndexed((int index, Asset asset) { children: assets.mapIndexed((int index, Asset asset) {
final bool last = index + 1 == assetsPerRow; final bool last = index + 1 == assetsPerRow;
final isSelected = isSelectionActive && selectedAssets.contains(asset);
return Container( return Container(
width: width * widthDistribution[index], width: width * widthDistribution[index],
height: width, height: width,
@ -822,21 +825,40 @@ class _AssetRow extends StatelessWidget {
bottom: margin, bottom: margin,
right: last ? 0.0 : margin, right: last ? 0.0 : margin,
), ),
child: AssetIndexWrapper( child: GestureDetector(
rowIndex: rowStartIndex + index, onTap: () {
sectionIndex: sectionIndex, if (selectionActive) {
child: ThumbnailImage( if (isSelected) {
asset: asset, onDeselect?.call(asset);
index: absoluteOffset + index, } else {
loadAsset: renderList.loadAsset, onSelect?.call(asset);
totalAssets: renderList.totalAssets, }
multiselectEnabled: selectionActive, } else {
isSelected: isSelectionActive && selectedAssets.contains(asset), context.pushRoute(
onSelect: () => onSelect?.call(asset), GalleryViewerRoute(
onDeselect: () => onDeselect?.call(asset), renderList: renderList,
showStorageIndicator: showStorageIndicator, initialIndex: absoluteOffset + index,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack, showStack: showStack,
),
);
}
},
onLongPress: () {
onSelect?.call(asset);
HapticFeedback.heavyImpact();
},
child: AssetIndexWrapper(
rowIndex: rowStartIndex + index,
sectionIndex: sectionIndex,
child: ThumbnailImage(
asset: asset,
multiselectEnabled: selectionActive,
isSelected: isSelectionActive && selectedAssets.contains(asset),
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
showStack: showStack,
),
), ),
), ),
); );

View File

@ -1,40 +1,42 @@
import 'package:auto_route/auto_route.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
import 'package:immich_mobile/utils/storage_indicator.dart'; import 'package:immich_mobile/utils/storage_indicator.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class ThumbnailImage extends ConsumerWidget { class ThumbnailImage extends ConsumerWidget {
/// The asset to show the thumbnail image for
final Asset asset; final Asset asset;
final int index;
final Asset Function(int index) loadAsset; /// Whether to show the storage indicator icont over the image or not
final int totalAssets;
final bool showStorageIndicator; final bool showStorageIndicator;
/// Whether to show the show stack icon over the image or not
final bool showStack; final bool showStack;
/// Whether to show the checkmark indicating that this image is selected
final bool isSelected; final bool isSelected;
/// Can override [isSelected] and never show the selection indicator
final bool multiselectEnabled; final bool multiselectEnabled;
final Function? onSelect;
final Function? onDeselect; /// If we are allowed to deselect this image
final bool canDeselect;
/// The offset index to apply to this hero tag for animation
final int heroOffset; final int heroOffset;
const ThumbnailImage({ const ThumbnailImage({
super.key, super.key,
required this.asset, required this.asset,
required this.index,
required this.loadAsset,
required this.totalAssets,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.showStack = false, this.showStack = false,
this.isSelected = false, this.isSelected = false,
this.multiselectEnabled = false, this.multiselectEnabled = false,
this.onDeselect,
this.onSelect,
this.heroOffset = 0, this.heroOffset = 0,
this.canDeselect = true,
}); });
@override @override
@ -147,11 +149,7 @@ class ThumbnailImage extends ConsumerWidget {
} }
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( color: canDeselect ? assetContainerColor : Colors.grey,
width: 0,
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
color: onDeselect == null ? Colors.grey : assetContainerColor,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@ -165,79 +163,52 @@ class ThumbnailImage extends ConsumerWidget {
); );
} }
return GestureDetector( return Stack(
onTap: () { children: [
if (multiselectEnabled) { AnimatedContainer(
if (isSelected) { duration: const Duration(milliseconds: 300),
onDeselect?.call(); curve: Curves.decelerate,
} else { decoration: BoxDecoration(
onSelect?.call(); border: multiselectEnabled && isSelected
} ? Border.all(
} else { color: canDeselect ? assetContainerColor : Colors.grey,
context.pushRoute( width: 8,
GalleryViewerRoute( )
initialIndex: index, : const Border(),
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
);
}
},
onLongPress: () {
onSelect?.call();
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
},
child: Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.decelerate,
decoration: BoxDecoration(
border: multiselectEnabled && isSelected
? Border.all(
color: onDeselect == null
? Colors.grey
: assetContainerColor,
width: 8,
)
: const Border(),
),
child: buildImage(),
), ),
if (multiselectEnabled) child: buildImage(),
Padding( ),
padding: const EdgeInsets.all(3.0), if (multiselectEnabled)
child: Align( Padding(
alignment: Alignment.topLeft, padding: const EdgeInsets.all(3.0),
child: buildSelectionIcon(asset), child: Align(
), alignment: Alignment.topLeft,
child: buildSelectionIcon(asset),
), ),
if (showStorageIndicator) ),
Positioned( if (showStorageIndicator)
right: 8, Positioned(
bottom: 5, right: 8,
child: Icon( bottom: 5,
storageIcon(asset), child: Icon(
color: Colors.white, storageIcon(asset),
size: 18, color: Colors.white,
), size: 18,
), ),
if (asset.isFavorite) ),
const Positioned( if (asset.isFavorite)
left: 8, const Positioned(
bottom: 5, left: 8,
child: Icon( bottom: 5,
Icons.favorite, child: Icon(
color: Colors.white, Icons.favorite,
size: 18, color: Colors.white,
), size: 18,
), ),
if (!asset.isImage) buildVideoIcon(), ),
if (asset.stackChildrenCount > 0) buildStackIcon(), if (!asset.isImage) buildVideoIcon(),
], if (asset.stackChildrenCount > 0) buildStackIcon(),
), ],
); );
} }
} }

View File

@ -10,6 +10,7 @@ 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/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/services/asset_stack.service.dart'; import 'package:immich_mobile/services/asset_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_viewer/video_controls.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -21,20 +22,24 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BottomGalleryBar extends ConsumerWidget { class BottomGalleryBar extends ConsumerWidget {
final Asset asset; final Asset asset;
final int assetIndex;
final bool showStack; final bool showStack;
final int stackIndex; final int stackIndex;
final int totalAssets; final ValueNotifier<int> totalAssets;
final bool showVideoPlayerControls; final bool showVideoPlayerControls;
final PageController controller; final PageController controller;
final RenderList renderList;
const BottomGalleryBar({ const BottomGalleryBar({
super.key, super.key,
required this.showStack, required this.showStack,
required this.stackIndex, required this.stackIndex,
required this.asset, required this.asset,
required this.assetIndex,
required this.controller, required this.controller,
required this.totalAssets, required this.totalAssets,
required this.showVideoPlayerControls, required this.showVideoPlayerControls,
required this.renderList,
}); });
@override @override
@ -108,16 +113,17 @@ class BottomGalleryBar extends ConsumerWidget {
force: force, force: force,
); );
if (isDeleted && isParent) { if (isDeleted && isParent) {
if (totalAssets == 1) { // 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 == totalAssets.value - 1) {
// Handle only one asset // Handle only one asset
context.maybePop(); context.maybePop();
} else {
// Go to next page otherwise
controller.nextPage(
duration: const Duration(milliseconds: 100),
curve: Curves.fastLinearToSlowEaseIn,
);
} }
totalAssets.value -= 1;
} }
return isDeleted; return isDeleted;
} }