From 730b770e67b35d0ad6f64f31deb6bc8c9df311c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20K=C3=B6cher?= Date: Thu, 12 Feb 2026 17:50:00 +0100 Subject: [PATCH] fix(mobile): timeline orientation & foldable phones handling (#25088) * [mobile]: Fix timeline handling on foldable phones + ensuring that images are not cut off This fixes the handling of unfolding the phone while having the application opened. So, the timeline is correctly rescaled and the current position is kept. Besides that it fixes a bug with the ordering which lead to images being "cut off" at the right side of the screen. * refactor + cleanup --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../widgets/timeline/timeline.widget.dart | 162 ++++++++++-------- 1 file changed, 87 insertions(+), 75 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index ac20e73190..da0497539b 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,7 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; -class Timeline extends StatelessWidget { +class Timeline extends ConsumerWidget { const Timeline({ super.key, this.topSliverWidget, @@ -58,15 +58,15 @@ class Timeline extends StatelessWidget { final bool readOnly; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( builder: (_, constraints) => ProviderScope( overrides: [ - timelineArgsProvider.overrideWith( - (ref) => TimelineArgs( + timelineArgsProvider.overrideWithValue( + TimelineArgs( maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight, columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), @@ -78,6 +78,7 @@ class Timeline extends StatelessWidget { if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), ], child: _SliverTimeline( + key: const ValueKey('_sliver_timeline'), topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, appBar: appBar, @@ -105,6 +106,7 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier { class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ + super.key, this.topSliverWidget, this.topSliverWidgetHeight, this.appBar, @@ -139,14 +141,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { int _perRow = 4; double _scaleFactor = 3.0; double _baseScaleFactor = 3.0; - int? _scaleRestoreAssetIndex; + int? _restoreAssetIndex; @override void initState() { super.initState(); _scrollController = ScrollController( initialScrollOffset: widget.initialScrollOffset ?? 0.0, - onAttach: _restoreScalePosition, + onAttach: _restoreAssetPosition, ); _eventSubscription = EventStream.shared.listen(_onEvent); @@ -179,14 +181,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { EventStream.shared.emit(MultiSelectToggleEvent(isEnabled)); } - void _restoreScalePosition(_) { - if (_scaleRestoreAssetIndex == null) return; + void _restoreAssetPosition(_) { + if (_restoreAssetIndex == null) return; final asyncSegments = ref.read(timelineSegmentProvider); asyncSegments.whenData((segments) { - final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _scaleRestoreAssetIndex!); + final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!); if (targetSegment != null) { - final assetIndexInSegment = _scaleRestoreAssetIndex! - targetSegment.firstAssetIndex; + final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex; final newColumnCount = ref.read(timelineArgsProvider).columnCount; final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor(); final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment; @@ -198,7 +200,25 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); } }); - _scaleRestoreAssetIndex = null; + _restoreAssetIndex = null; + } + + int? _getCurrentAssetIndex(List segments) { + final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent); + final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; + int? targetAssetIndex; + if (segment != null) { + final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset); + if (rowIndex > segment.firstIndex) { + final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); + final assetsPerRow = ref.read(timelineArgsProvider).columnCount; + final assetIndexInSegment = rowIndexInSegment * assetsPerRow; + targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment; + } else { + targetAssetIndex = segment.firstAssetIndex; + } + } + return targetAssetIndex; } @override @@ -387,74 +407,66 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return PrimaryScrollController( controller: _scrollController, - child: RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - _baseScaleFactor = _scaleFactor; - }; - - scale.onUpdate = (details) { - final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); - final newPerRow = 7 - newScaleFactor.toInt(); - - if (newPerRow != _perRow) { - final currentOffset = _scrollController.offset.clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; - int? targetAssetIndex; - if (segment != null) { - final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset); - if (rowIndex > segment.firstIndex) { - final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); - final assetsPerRow = ref.read(timelineArgsProvider).columnCount; - final assetIndexInSegment = rowIndexInSegment * assetsPerRow; - targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment; - } else { - targetAssetIndex = segment.firstAssetIndex; - } - } - - setState(() { - _scaleFactor = newScaleFactor; - _perRow = newPerRow; - _scaleRestoreAssetIndex = targetAssetIndex; - }); - - ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); - } - }; - }, - ), + child: NotificationListener( + onNotification: (notification) { + final currentIndex = _getCurrentAssetIndex(segments); + if (currentIndex != null && mounted) { + _restoreAssetIndex = currentIndex; + } + return false; }, - child: TimelineDragRegion( - onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, - onAssetEnter: _handleDragAssetEnter, - onEnd: !isReadonlyModeEnabled ? _stopDrag : null, - onScroll: _dragScroll, - onScrollStart: () { - // Minimize the bottom sheet when drag selection starts - ref.read(timelineStateProvider.notifier).setScrolling(true); + child: RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer scale) { + scale.onStart = (details) { + _baseScaleFactor = _scaleFactor; + }; + + scale.onUpdate = (details) { + final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0); + final newPerRow = 7 - newScaleFactor.toInt(); + final targetAssetIndex = _getCurrentAssetIndex(segments); + + if (newPerRow != _perRow) { + setState(() { + _scaleFactor = newScaleFactor; + _perRow = newPerRow; + _restoreAssetIndex = targetAssetIndex; + }); + + ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); + } + }; + }, + ), }, - child: Stack( - children: [ - timeline, - if (!isSelectionMode && isMultiSelectEnabled) ...[ - Positioned( - top: MediaQuery.paddingOf(context).top, - left: 25, - child: const SizedBox( - height: kToolbarHeight, - child: Center(child: _MultiSelectStatusButton()), + child: TimelineDragRegion( + onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, + onAssetEnter: _handleDragAssetEnter, + onEnd: !isReadonlyModeEnabled ? _stopDrag : null, + onScroll: _dragScroll, + onScrollStart: () { + // Minimize the bottom sheet when drag selection starts + ref.read(timelineStateProvider.notifier).setScrolling(true); + }, + child: Stack( + children: [ + timeline, + if (!isSelectionMode && isMultiSelectEnabled) ...[ + Positioned( + top: MediaQuery.paddingOf(context).top, + left: 25, + child: const SizedBox( + height: kToolbarHeight, + child: Center(child: _MultiSelectStatusButton()), + ), ), - ), - if (widget.bottomSheet != null) widget.bottomSheet!, + if (widget.bottomSheet != null) widget.bottomSheet!, + ], ], - ], + ), ), ), ),