diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..e4f50f9ef6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter analyze:*)" + ] + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 19669e0307..01f80bfe9a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -101,7 +101,9 @@ class AssetViewer extends ConsumerStatefulWidget { } } -class _AssetViewerState extends ConsumerState { +enum _DragMode { undecided, dismiss, scroll } + +class _AssetViewerState extends ConsumerState with TickerProviderStateMixin { static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); late PageController pageController; // PhotoViewGallery takes care of disposing it's controllers @@ -110,10 +112,11 @@ class _AssetViewerState extends ConsumerState { late final int heroOffset; late PhotoViewControllerValue initialPhotoViewState; - bool? hasDraggedDown; bool blockGestures = false; bool dragInProgress = false; bool shouldPopOnDrag = false; + _DragMode _dragMode = _DragMode.undecided; + Offset _dragStartGlobalPosition = Offset.zero; bool assetReloadRequested = false; Offset dragDownPosition = Offset.zero; int totalAssets = 0; @@ -130,9 +133,9 @@ class _AssetViewerState extends ConsumerState { KeepAliveLink? _stackChildrenKeepAlive; final ScrollController _scrollController = ScrollController(); + late final AnimationController _ballisticAnimController; double _assetDetailsOpacity = 0.0; - - // final _assetDetailsSnap = 5; + double _currentSnapOffset = 0.0; @override void initState() { @@ -140,6 +143,7 @@ class _AssetViewerState extends ConsumerState { assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); pageController = PageController(initialPage: widget.initialIndex); _scrollController.addListener(_onScroll); + _ballisticAnimController = AnimationController.unbounded(vsync: this)..addListener(_onBallisticTick); totalAssets = ref.read(timelineServiceProvider).totalAssets; WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); reloadSubscription = EventStream.shared.listen(_onEvent); @@ -156,20 +160,106 @@ class _AssetViewerState extends ConsumerState { } void _onScroll() { - setState(() { - // _assetDetailsOpacity = (_scrollController.offset / 50).clamp(0.0, 1.0); - _assetDetailsOpacity = _scrollController.offset > 10 ? 1 : 0; - + final newOpacity = _scrollController.offset > 5 ? 1.0 : 0.0; + if (newOpacity != _assetDetailsOpacity) { + setState(() { + _assetDetailsOpacity = newOpacity; + }); if (_assetDetailsOpacity == 0) { ref.read(assetViewerProvider.notifier).setControls(true); } else { ref.read(assetViewerProvider.notifier).setControls(false); } - }); + } + } + + static const _scrollTolerance = Tolerance(distance: 0.5, velocity: 10.0); + + void _onBallisticTick() { + if (!_scrollController.hasClients) return; + final raw = _ballisticAnimController.value; + final max = _scrollController.position.maxScrollExtent; + final offset = raw.clamp(0.0, max); + final prevOffset = _scrollController.offset; + + // Stop at bounds + if (raw != offset) { + _ballisticAnimController.stop(); + _scrollController.jumpTo(offset); + return; + } + + // During free-scroll deceleration, don't cross into the snap zone. + // Stop at snapOffset so the user can release there cleanly. + final snap = _currentSnapOffset; + if (prevOffset >= snap && offset < snap) { + _ballisticAnimController.stop(); + _scrollController.jumpTo(snap); + return; + } + + _scrollController.jumpTo(offset); + } + + void _snapScroll(double velocity) { + if (!_scrollController.hasClients) return; + final offset = _scrollController.offset; + final snap = _currentSnapOffset; + if (snap <= 0) return; + + // Above snap offset: free scroll with deceleration + if (offset >= snap) { + if (velocity.abs() < 10) return; + // Scrolling down towards snap zone: snap + if (velocity < -50) { + _ballisticAnimController.value = offset; + _ballisticAnimController.animateWith( + ScrollSpringSimulation( + SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0), + offset, + snap, + velocity, + tolerance: _scrollTolerance, + ), + ); + return; + } + // Scrolling up: decelerate naturally + _ballisticAnimController.value = offset; + _ballisticAnimController.animateWith( + ClampingScrollSimulation(position: offset, velocity: velocity, tolerance: _scrollTolerance), + ); + return; + } + + // In snap zone (0 → snapOffset): snap to nearest target + double target; + if (velocity.abs() > 50) { + target = velocity > 0 ? snap : 0; + } else { + target = (offset < snap / 2) ? 0 : snap; + } + + if ((offset - target).abs() < 0.5) { + _scrollController.jumpTo(target); + return; + } + + _ballisticAnimController.value = offset; + _ballisticAnimController.animateWith( + ScrollSpringSimulation( + SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0), + offset, + target, + velocity, + tolerance: _scrollTolerance, + ), + ); } @override void dispose() { + _ballisticAnimController.dispose(); _scrollController.dispose(); pageController.dispose(); _cancelTimers(); @@ -229,6 +319,8 @@ class _AssetViewerState extends ConsumerState { } void _onAssetChanged(int index) async { + _ballisticAnimController.stop(); + if (_scrollController.hasClients) _scrollController.jumpTo(0); final timelineService = ref.read(timelineServiceProvider); final asset = await timelineService.getAssetAsync(index); if (asset == null) { @@ -299,10 +391,12 @@ class _AssetViewerState extends ConsumerState { PhotoViewControllerBase controller, PhotoViewScaleStateController scaleStateController, ) { - print("photoview drag start"); + _ballisticAnimController.stop(); viewController = controller; dragDownPosition = details.localPosition; + _dragStartGlobalPosition = details.globalPosition; initialPhotoViewState = controller.value; + _dragMode = showingBottomSheet ? _DragMode.scroll : _DragMode.undecided; final isZoomed = scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || scaleStateController.scaleState == PhotoViewScaleState.covering; @@ -311,28 +405,29 @@ class _AssetViewerState extends ConsumerState { } } - void _onDragEnd(BuildContext ctx, _, __) { + void _onDragEnd(BuildContext ctx, DragEndDetails details, _) { dragInProgress = false; + final mode = _dragMode; + _dragMode = _DragMode.undecided; + + if (mode == _DragMode.scroll) { + // Convert finger velocity to scroll velocity (inverted) + final scrollVelocity = -details.velocity.pixelsPerSecond.dy; + _snapScroll(scrollVelocity); + return; + } if (shouldPopOnDrag) { - // Dismiss immediately without state updates to avoid rebuilds ctx.maybePop(); return; } - // Do not reset the state if the bottom sheet is showing - if (showingBottomSheet) { - return; - } - - // If the gestures are blocked, do not reset the state if (blockGestures) { blockGestures = false; return; } shouldPopOnDrag = false; - hasDraggedDown = null; viewController?.animateMultiple( position: initialPhotoViewState.position, scale: viewController?.initialScale ?? initialPhotoViewState.scale, @@ -342,18 +437,34 @@ class _AssetViewerState extends ConsumerState { } void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { - if (blockGestures) { - return; - } - + if (blockGestures) return; dragInProgress = true; - final delta = details.localPosition - dragDownPosition; - hasDraggedDown ??= delta.dy > 0; - if (!hasDraggedDown! || showingBottomSheet) { + + if (_dragMode == _DragMode.undecided) { + final globalDelta = details.globalPosition - _dragStartGlobalPosition; + if (globalDelta.dy > 1) { + _dragMode = _DragMode.dismiss; + } else if (globalDelta.dy < -1) { + _dragMode = _DragMode.scroll; + } else { + return; + } + // Fall through to process this update immediately + } + + if (_dragMode == _DragMode.dismiss) { + final delta = details.localPosition - dragDownPosition; + _handleDragDown(ctx, delta); return; } - _handleDragDown(ctx, delta); + // Scroll mode: drive the scroll controller with incremental delta + if (!_scrollController.hasClients) return; + final newOffset = (_scrollController.offset - details.delta.dy).clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + _scrollController.jumpTo(newOffset); } void _handleDragDown(BuildContext ctx, Offset delta) { @@ -384,6 +495,24 @@ class _AssetViewerState extends ConsumerState { } } + void _onDetailsDragStart(DragStartDetails details) { + _ballisticAnimController.stop(); + } + + void _onDetailsDragUpdate(DragUpdateDetails details) { + if (!_scrollController.hasClients) return; + final newOffset = (_scrollController.offset - details.delta.dy).clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + _scrollController.jumpTo(newOffset); + } + + void _onDetailsDragEnd(DragEndDetails details) { + final scrollVelocity = -details.velocity.pixelsPerSecond.dy; + _snapScroll(scrollVelocity); + } + void _onEvent(Event event) { if (event is TimelineReloadEvent) { _onTimelineReloadEvent(); @@ -394,6 +523,26 @@ class _AssetViewerState extends ConsumerState { assetReloadRequested = true; return; } + + if (event is ViewerOpenBottomSheetEvent) { + _openDetails(); + return; + } + } + + void _openDetails() { + if (!_scrollController.hasClients || _currentSnapOffset <= 0) return; + _ballisticAnimController.stop(); + _ballisticAnimController.value = _scrollController.offset; + _ballisticAnimController.animateWith( + ScrollSpringSimulation( + SpringDescription.withDampingRatio(mass: 0.5, stiffness: 100.0, ratio: 1.0), + _scrollController.offset, + _currentSnapOffset, + 0, + tolerance: _scrollTolerance, + ), + ); } void _onTimelineReloadEvent() { @@ -617,7 +766,8 @@ class _AssetViewerState extends ConsumerState { // Calculate padding to center the image in the viewport final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); - final snapOffset = math.max(topPadding + (imageHeight / 2), viewportHeight / 3 * 2); + final snapOffset = (topPadding + (imageHeight / 2)).clamp(viewportHeight / 3, viewportHeight / 2); + _currentSnapOffset = snapOffset; return Stack( clipBehavior: Clip.none, @@ -626,7 +776,7 @@ class _AssetViewerState extends ConsumerState { onNotification: onScrollNotification, child: SingleChildScrollView( controller: _scrollController, - physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset), + physics: const NeverScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -656,12 +806,15 @@ class _AssetViewerState extends ConsumerState { ), ), - // TODO: if zooming, this should be hidden, and we should - // probably disable the scroll physics - AnimatedOpacity( - opacity: _assetDetailsOpacity, - duration: kThemeAnimationDuration, - child: AssetDetails(minHeight: snapOffset), + GestureDetector( + onVerticalDragStart: _onDetailsDragStart, + onVerticalDragUpdate: _onDetailsDragUpdate, + onVerticalDragEnd: _onDetailsDragEnd, + child: AnimatedOpacity( + opacity: _assetDetailsOpacity, + duration: kThemeAnimationDuration, + child: AssetDetails(minHeight: viewportHeight - snapOffset), + ), ), ], ),