remove bottom sheet

This commit is contained in:
Thomas Way
2026-02-05 17:14:59 +00:00
parent 59af9e087b
commit 53c5ff1cd7
@@ -16,13 +16,11 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
@@ -103,14 +101,9 @@ class AssetViewer extends ConsumerStatefulWidget {
} }
} }
const double _kBottomSheetMinimumExtent = 0.4;
const double _kBottomSheetSnapExtent = 0.67;
class _AssetViewerState extends ConsumerState<AssetViewer> { class _AssetViewerState extends ConsumerState<AssetViewer> {
static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
late PageController pageController; late PageController pageController;
late DraggableScrollableController bottomSheetController;
PersistentBottomSheetController? sheetCloseController;
// PhotoViewGallery takes care of disposing it's controllers // PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController; PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription; StreamSubscription? reloadSubscription;
@@ -123,7 +116,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool dragInProgress = false; bool dragInProgress = false;
bool shouldPopOnDrag = false; bool shouldPopOnDrag = false;
bool assetReloadRequested = false; bool assetReloadRequested = false;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero; Offset dragDownPosition = Offset.zero;
int totalAssets = 0; int totalAssets = 0;
int stackIndex = 0; int stackIndex = 0;
@@ -141,6 +133,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
double _assetDetailsOpacity = 0.0; double _assetDetailsOpacity = 0.0;
final _assetDetailsSnap = 5;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -148,7 +142,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
pageController = PageController(initialPage: widget.initialIndex); pageController = PageController(initialPage: widget.initialIndex);
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
totalAssets = ref.read(timelineServiceProvider).totalAssets; totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent); reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
@@ -180,7 +173,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
pageController.dispose(); pageController.dispose();
bottomSheetController.dispose();
_cancelTimers(); _cancelTimers();
reloadSubscription?.cancel(); reloadSubscription?.cancel();
_prevPreCacheStream?.removeListener(_dummyListener); _prevPreCacheStream?.removeListener(_dummyListener);
@@ -190,7 +182,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose(); super.dispose();
} }
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); bool get showingBottomSheet => _scrollController.offset > 0;
Color get backgroundColor { Color get backgroundColor {
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
@@ -204,9 +196,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_delayedOperations.clear(); _delayedOperations.clear();
} }
double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
ImageStream _precacheImage(BaseAsset asset) { ImageStream _precacheImage(BaseAsset asset) {
final provider = getFullImageProvider(asset, size: context.sizeData); final provider = getFullImageProvider(asset, size: context.sizeData);
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
@@ -284,17 +273,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
} }
void _onPageBuild(PhotoViewControllerBase controller) {
viewController ??= controller;
if (showingBottomSheet && bottomSheetController.isAttached) {
final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
void _onPageChanged(int index, PhotoViewControllerBase? controller) { void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index); _onAssetChanged(index);
viewController = controller; viewController = controller;
@@ -328,7 +306,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Do not reset the state if the bottom sheet is showing // Do not reset the state if the bottom sheet is showing
if (showingBottomSheet) { if (showingBottomSheet) {
_snapBottomSheet();
return; return;
} }
@@ -357,32 +334,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final delta = details.localPosition - dragDownPosition; final delta = details.localPosition - dragDownPosition;
hasDraggedDown ??= delta.dy > 0; hasDraggedDown ??= delta.dy > 0;
if (!hasDraggedDown! || showingBottomSheet) { if (!hasDraggedDown! || showingBottomSheet) {
_handleDragUp(ctx, delta);
return; return;
} }
_handleDragDown(ctx, delta); _handleDragDown(ctx, delta);
} }
void _handleDragUp(BuildContext ctx, Offset delta) {
const double openThreshold = 50;
final position = initialPhotoViewState.position + Offset(0, delta.dy);
final distanceToOrigin = position.distance;
viewController?.updateMultiple(position: position);
// Moves the bottom sheet when the asset is being dragged up
if (showingBottomSheet && bottomSheetController.isAttached) {
final centre = (ctx.height * _kBottomSheetMinimumExtent);
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
}
if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) {
// _openBottomSheet(ctx);
}
}
void _handleDragDown(BuildContext ctx, Offset delta) { void _handleDragDown(BuildContext ctx, Offset delta) {
print("drag down");
const double dragRatio = 0.2; const double dragRatio = 0.2;
const double popThreshold = 75; const double popThreshold = 75;
@@ -409,48 +368,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
} }
bool _onNotification(Notification delta) {
if (delta is DraggableScrollableNotification) {
_handleDraggableNotification(delta);
}
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
// the isSnapping guard is to prevent the notification from recursively handling the
// notification, eventually resulting in a heap overflow
if (!isSnapping && delta is ScrollEndNotification) {
_snapBottomSheet();
}
return false;
}
void _handleDraggableNotification(DraggableScrollableNotification delta) {
final currentExtent = delta.extent;
final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.67) {
if (dragInProgress) {
blockGestures = true;
}
// Jump to a lower position before starting close animation to prevent glitch
if (bottomSheetController.isAttached) {
bottomSheetController.jumpTo(0.67);
}
sheetCloseController?.close();
}
// If the asset is being dragged down, we do not want to update the asset position again
if (dragInProgress) {
return;
}
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
// Moves the asset when the bottom sheet is being dragged
if (verticalOffset > 0) {
viewController?.position = Offset(0, -verticalOffset);
}
}
void _onEvent(Event event) { void _onEvent(Event event) {
if (event is TimelineReloadEvent) { if (event is TimelineReloadEvent) {
_onTimelineReloadEvent(); _onTimelineReloadEvent();
@@ -461,14 +378,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
assetReloadRequested = true; assetReloadRequested = true;
return; return;
} }
if (event is ViewerOpenBottomSheetEvent) {
final extent = _kBottomSheetMinimumExtent + 0.3;
_openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode);
final offset = _getVerticalOffsetForBottomSheet(extent);
viewController?.position = Offset(0, -offset);
return;
}
} }
void _onTimelineReloadEvent() { void _onTimelineReloadEvent() {
@@ -502,50 +411,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
setState(() { setState(() {
_onAssetChanged(pageController.page!.round()); _onAssetChanged(pageController.page!.round());
sheetCloseController?.close();
}); });
} }
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
constraints: const BoxConstraints(maxWidth: double.infinity),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
builder: (_) {
return NotificationListener<Notification>(
onNotification: _onNotification,
child: activitiesMode
? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent)
: AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent),
);
},
);
sheetCloseController?.closed.then((_) => _handleSheetClose());
}
void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;
hasDraggedDown = null;
}
void _snapBottomSheet() {
if (!bottomSheetController.isAttached ||
bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
}
isSnapping = true;
bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut);
}
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
return const Center(child: ImmichLoadingIndicator()); return const Center(child: ImmichLoadingIndicator());
} }
@@ -666,7 +534,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes // Rebuild the widget when the asset viewer state changes
// Using multiple selectors to avoid unnecessary rebuilds for other state changes // Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider); ref.watch(isPlayingMotionVideoProvider);
@@ -734,70 +601,54 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0);
final snapOffset = math.min(topPadding + (imageHeight / 2), (viewportHeight / 3) * 2); final snapOffset = math.min(topPadding + (imageHeight / 2), (viewportHeight / 3) * 2);
return SingleChildScrollView( return Stack(
controller: _scrollController, clipBehavior: Clip.none,
physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset), children: [
child: Column( SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.stretch, controller: _scrollController,
children: [ physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset),
SizedBox( child: Column(
height: imageHeight + topPadding, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Stack( children: [
clipBehavior: Clip.none, SizedBox(height: topPadding),
children: [ SizedBox(
Column( height: imageHeight,
children: [ child: PhotoViewGallery.builder(
SizedBox(height: topPadding), gaplessPlayback: true,
SizedBox( loadingBuilder: _placeholderBuilder,
height: imageHeight, pageController: pageController,
child: PhotoViewGallery.builder( scrollPhysics: CurrentPlatform.isIOS
gaplessPlayback: true, ? const FastScrollPhysics() // Use bouncing physics for iOS
loadingBuilder: _placeholderBuilder, : const FastClampingScrollPhysics(), // Use heavy physics for Android
pageController: pageController, itemCount: totalAssets,
scrollPhysics: CurrentPlatform.isIOS onPageChanged: _onPageChanged,
? const FastScrollPhysics() // Use bouncing physics for iOS scaleStateChangedCallback: _onScaleStateChanged,
: const FastClampingScrollPhysics(), // Use heavy physics for Android builder: _assetBuilder,
itemCount: totalAssets, backgroundDecoration: BoxDecoration(color: backgroundColor),
onPageChanged: _onPageChanged, enablePanAlways: true,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
),
],
), ),
Positioned( ),
height: viewportHeight, AnimatedOpacity(
top: 0, opacity: _assetDetailsOpacity,
left: 0, duration: kThemeAnimationDuration,
right: 0, child: AssetDetails(minHeight: viewportHeight / 3 * 2),
child: const IgnorePointer( ),
// TODO: Sync with whether it's visible? ],
// TODO: Hide on scroll
ignoring: true,
child: Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
),
),
),
),
],
),
), ),
AnimatedOpacity( ),
opacity: _assetDetailsOpacity, Positioned(
duration: kThemeAnimationDuration, height: viewportHeight,
child: AssetDetails(minHeight: viewportHeight / 3 * 2), bottom: 0,
left: 0,
right: 0,
child: const Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [AssetStackRow(), ViewerBottomBar()],
), ),
], ),
), ],
); );
}, },
), ),