mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fully working
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(flutter analyze:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,7 +101,9 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
enum _DragMode { undecided, dismiss, scroll }
|
||||||
|
|
||||||
|
class _AssetViewerState extends ConsumerState<AssetViewer> with TickerProviderStateMixin {
|
||||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||||
late PageController pageController;
|
late PageController pageController;
|
||||||
// PhotoViewGallery takes care of disposing it's controllers
|
// PhotoViewGallery takes care of disposing it's controllers
|
||||||
@@ -110,10 +112,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
late final int heroOffset;
|
late final int heroOffset;
|
||||||
late PhotoViewControllerValue initialPhotoViewState;
|
late PhotoViewControllerValue initialPhotoViewState;
|
||||||
bool? hasDraggedDown;
|
|
||||||
bool blockGestures = false;
|
bool blockGestures = false;
|
||||||
bool dragInProgress = false;
|
bool dragInProgress = false;
|
||||||
bool shouldPopOnDrag = false;
|
bool shouldPopOnDrag = false;
|
||||||
|
_DragMode _dragMode = _DragMode.undecided;
|
||||||
|
Offset _dragStartGlobalPosition = Offset.zero;
|
||||||
bool assetReloadRequested = false;
|
bool assetReloadRequested = false;
|
||||||
Offset dragDownPosition = Offset.zero;
|
Offset dragDownPosition = Offset.zero;
|
||||||
int totalAssets = 0;
|
int totalAssets = 0;
|
||||||
@@ -130,9 +133,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
KeepAliveLink? _stackChildrenKeepAlive;
|
KeepAliveLink? _stackChildrenKeepAlive;
|
||||||
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
late final AnimationController _ballisticAnimController;
|
||||||
double _assetDetailsOpacity = 0.0;
|
double _assetDetailsOpacity = 0.0;
|
||||||
|
double _currentSnapOffset = 0.0;
|
||||||
// final _assetDetailsSnap = 5;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -140,6 +143,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
|
||||||
pageController = PageController(initialPage: widget.initialIndex);
|
pageController = PageController(initialPage: widget.initialIndex);
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
_ballisticAnimController = AnimationController.unbounded(vsync: this)..addListener(_onBallisticTick);
|
||||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||||
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
|
||||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||||
@@ -156,20 +160,106 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
setState(() {
|
final newOpacity = _scrollController.offset > 5 ? 1.0 : 0.0;
|
||||||
// _assetDetailsOpacity = (_scrollController.offset / 50).clamp(0.0, 1.0);
|
if (newOpacity != _assetDetailsOpacity) {
|
||||||
_assetDetailsOpacity = _scrollController.offset > 10 ? 1 : 0;
|
setState(() {
|
||||||
|
_assetDetailsOpacity = newOpacity;
|
||||||
|
});
|
||||||
if (_assetDetailsOpacity == 0) {
|
if (_assetDetailsOpacity == 0) {
|
||||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||||
} else {
|
} else {
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_ballisticAnimController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
pageController.dispose();
|
pageController.dispose();
|
||||||
_cancelTimers();
|
_cancelTimers();
|
||||||
@@ -229,6 +319,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetChanged(int index) async {
|
void _onAssetChanged(int index) async {
|
||||||
|
_ballisticAnimController.stop();
|
||||||
|
if (_scrollController.hasClients) _scrollController.jumpTo(0);
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final asset = await timelineService.getAssetAsync(index);
|
final asset = await timelineService.getAssetAsync(index);
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
@@ -299,10 +391,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
PhotoViewControllerBase controller,
|
PhotoViewControllerBase controller,
|
||||||
PhotoViewScaleStateController scaleStateController,
|
PhotoViewScaleStateController scaleStateController,
|
||||||
) {
|
) {
|
||||||
print("photoview drag start");
|
_ballisticAnimController.stop();
|
||||||
viewController = controller;
|
viewController = controller;
|
||||||
dragDownPosition = details.localPosition;
|
dragDownPosition = details.localPosition;
|
||||||
|
_dragStartGlobalPosition = details.globalPosition;
|
||||||
initialPhotoViewState = controller.value;
|
initialPhotoViewState = controller.value;
|
||||||
|
_dragMode = showingBottomSheet ? _DragMode.scroll : _DragMode.undecided;
|
||||||
final isZoomed =
|
final isZoomed =
|
||||||
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
|
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
|
||||||
scaleStateController.scaleState == PhotoViewScaleState.covering;
|
scaleStateController.scaleState == PhotoViewScaleState.covering;
|
||||||
@@ -311,28 +405,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(BuildContext ctx, _, __) {
|
void _onDragEnd(BuildContext ctx, DragEndDetails details, _) {
|
||||||
dragInProgress = false;
|
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) {
|
if (shouldPopOnDrag) {
|
||||||
// Dismiss immediately without state updates to avoid rebuilds
|
|
||||||
ctx.maybePop();
|
ctx.maybePop();
|
||||||
return;
|
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) {
|
if (blockGestures) {
|
||||||
blockGestures = false;
|
blockGestures = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldPopOnDrag = false;
|
shouldPopOnDrag = false;
|
||||||
hasDraggedDown = null;
|
|
||||||
viewController?.animateMultiple(
|
viewController?.animateMultiple(
|
||||||
position: initialPhotoViewState.position,
|
position: initialPhotoViewState.position,
|
||||||
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
|
||||||
@@ -342,18 +437,34 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
||||||
if (blockGestures) {
|
if (blockGestures) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dragInProgress = true;
|
dragInProgress = true;
|
||||||
final delta = details.localPosition - dragDownPosition;
|
|
||||||
hasDraggedDown ??= delta.dy > 0;
|
if (_dragMode == _DragMode.undecided) {
|
||||||
if (!hasDraggedDown! || showingBottomSheet) {
|
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;
|
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) {
|
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||||
@@ -384,6 +495,24 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
void _onEvent(Event event) {
|
||||||
if (event is TimelineReloadEvent) {
|
if (event is TimelineReloadEvent) {
|
||||||
_onTimelineReloadEvent();
|
_onTimelineReloadEvent();
|
||||||
@@ -394,6 +523,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
assetReloadRequested = true;
|
assetReloadRequested = true;
|
||||||
return;
|
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() {
|
void _onTimelineReloadEvent() {
|
||||||
@@ -617,7 +766,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
// Calculate padding to center the image in the viewport
|
// Calculate padding to center the image in the viewport
|
||||||
final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0);
|
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(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -626,7 +776,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
onNotification: onScrollNotification,
|
onNotification: onScrollNotification,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -656,12 +806,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// TODO: if zooming, this should be hidden, and we should
|
GestureDetector(
|
||||||
// probably disable the scroll physics
|
onVerticalDragStart: _onDetailsDragStart,
|
||||||
AnimatedOpacity(
|
onVerticalDragUpdate: _onDetailsDragUpdate,
|
||||||
opacity: _assetDetailsOpacity,
|
onVerticalDragEnd: _onDetailsDragEnd,
|
||||||
duration: kThemeAnimationDuration,
|
child: AnimatedOpacity(
|
||||||
child: AssetDetails(minHeight: snapOffset),
|
opacity: _assetDetailsOpacity,
|
||||||
|
duration: kThemeAnimationDuration,
|
||||||
|
child: AssetDetails(minHeight: viewportHeight - snapOffset),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user