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>
This commit is contained in:
Bastian Köcher 2026-02-12 17:50:00 +01:00 committed by GitHub
parent b85f6f3fce
commit 730b770e67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<Segment> 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,6 +407,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
return PrimaryScrollController(
controller: _scrollController,
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final currentIndex = _getCurrentAssetIndex(segments);
if (currentIndex != null && mounted) {
_restoreAssetIndex = currentIndex;
}
return false;
},
child: RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
@ -399,30 +427,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
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) {
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;
_restoreAssetIndex = targetAssetIndex;
});
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
@ -458,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
),
),
),
),
);
},
),