diff --git a/mobile/lib/presentation/widgets/timeline/fixed/row.dart b/mobile/lib/presentation/widgets/timeline/fixed/row.dart index 3fe3cea3c9..97067add24 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/row.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/row.dart @@ -1,27 +1,45 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -class FixedTimelineRow extends MultiChildRenderObjectWidget { - final double dimension; +class TimelineRow extends MultiChildRenderObjectWidget { + final double height; + final List widths; final double spacing; final TextDirection textDirection; - const FixedTimelineRow({ + const TimelineRow({ super.key, - required this.dimension, + required this.height, + required this.widths, required this.spacing, required this.textDirection, required super.children, }); + factory TimelineRow.fixed({ + required double dimension, + required double spacing, + required TextDirection textDirection, + required List children, + }) => TimelineRow( + height: dimension, + widths: List.filled(children.length, dimension), + spacing: spacing, + textDirection: textDirection, + children: children, + ); + @override RenderObject createRenderObject(BuildContext context) { - return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection); + return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection); } @override void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { - renderObject.dimension = dimension; + renderObject.height = height; + renderObject.widths = widths; renderObject.spacing = spacing; renderObject.textDirection = textDirection; } @@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('height', height)); + properties.add(DiagnosticsProperty>('widths', widths)); properties.add(DoubleProperty('spacing', spacing)); properties.add(EnumProperty('textDirection', textDirection)); } @@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox RenderBoxContainerDefaultsMixin { RenderFixedRow({ List? children, - required double dimension, + required double height, + required List widths, required double spacing, required TextDirection textDirection, - }) : _dimension = dimension, + }) : _height = height, + _widths = widths, _spacing = spacing, _textDirection = textDirection { addAll(children); } - double get dimension => _dimension; - double _dimension; + double get height => _height; + double _height; - set dimension(double value) { - if (_dimension == value) return; - _dimension = value; + set height(double value) { + if (_height == value) return; + _height = value; + markNeedsLayout(); + } + + List get widths => _widths; + List _widths; + + set widths(List value) { + if (listEquals(_widths, value)) return; + _widths = value; markNeedsLayout(); } @@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox } } - double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1); + double get intrinsicWidth => widths.sum + (spacing * (childCount - 1)); @override double computeMinIntrinsicWidth(double height) => intrinsicWidth; @@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox double computeMaxIntrinsicWidth(double height) => intrinsicWidth; @override - double computeMinIntrinsicHeight(double width) => dimension; + double computeMinIntrinsicHeight(double width) => height; @override - double computeMaxIntrinsicHeight(double width) => dimension; + double computeMaxIntrinsicHeight(double width) => height; @override double? computeDistanceToActualBaseline(TextBaseline baseline) { @@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('height', height)); + properties.add(DiagnosticsProperty>('widths', widths)); properties.add(DoubleProperty('spacing', spacing)); properties.add(EnumProperty('textDirection', textDirection)); } @@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox return; } // Use the entire width of the parent for the row. - size = Size(constraints.maxWidth, dimension); - // Each tile is forced to be dimension x dimension. - final childConstraints = BoxConstraints.tight(Size(dimension, dimension)); + size = Size(constraints.maxWidth, height); + final flipMainAxis = textDirection == TextDirection.rtl; - Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); - final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); + int childIndex = 0; + double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0; // Layout each child horizontally. - while (child != null) { + while (child != null && childIndex < widths.length) { + final width = widths[childIndex]; + final childConstraints = BoxConstraints.tight(Size(width, height)); child.layout(childConstraints, parentUsesSize: false); final childParentData = child.parentData! as _RowParentData; - childParentData.offset = offset; - offset += Offset(dx, 0); + childParentData.offset = Offset(currentX, 0); child = childParentData.nextSibling; + childIndex++; + + if (child != null && childIndex < widths.length) { + final nextWidth = widths[childIndex]; + currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing; + } } } } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index b879b33f68..7551963ddf 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -14,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; @@ -21,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; class FixedSegment extends Segment { final double tileHeight; @@ -78,6 +81,7 @@ class FixedSegment extends Segment { assetCount: numberOfAssets, tileHeight: tileHeight, spacing: spacing, + columnCount: columnCount, ); } } @@ -87,24 +91,34 @@ class _FixedSegmentRow extends ConsumerWidget { final int assetCount; final double tileHeight; final double spacing; + final int columnCount; const _FixedSegmentRow({ required this.assetIndex, required this.assetCount, required this.tileHeight, required this.spacing, + required this.columnCount, }); @override Widget build(BuildContext context, WidgetRef ref) { final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final timelineService = ref.read(timelineServiceProvider); + final isDynamicLayout = ref.watch( + appSettingsServiceProvider.select((s) => s.getSetting(AppSettingsEnum.dynamicLayout)), + ); if (isScrubbing) { return _buildPlaceholder(context); } if (timelineService.hasRange(assetIndex, assetCount)) { - return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); + return _buildAssetRow( + context, + timelineService.getAssets(assetIndex, assetCount), + timelineService, + isDynamicLayout, + ); } return FutureBuilder>( @@ -113,7 +127,7 @@ class _FixedSegmentRow extends ConsumerWidget { if (snapshot.connectionState != ConnectionState.done) { return _buildPlaceholder(context); } - return _buildAssetRow(context, snapshot.requireData, timelineService); + return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout); }, ); } @@ -122,23 +136,58 @@ class _FixedSegmentRow extends ConsumerWidget { return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing); } - Widget _buildAssetRow(BuildContext context, List assets, TimelineService timelineService) { - return FixedTimelineRow( - dimension: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: [ - for (int i = 0; i < assets.length; i++) - TimelineAssetIndexWrapper( + Widget _buildAssetRow( + BuildContext context, + List assets, + TimelineService timelineService, + bool isDynamicLayout, + ) { + final children = [ + for (int i = 0; i < assets.length; i++) + TimelineAssetIndexWrapper( + assetIndex: assetIndex + i, + segmentIndex: 0, // For simplicity, using 0 for now + child: _AssetTileWidget( + key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), + asset: assets[i], assetIndex: assetIndex + i, - segmentIndex: 0, // For simplicity, using 0 for now - child: _AssetTileWidget( - key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), - asset: assets[i], - assetIndex: assetIndex + i, - ), ), - ], + ), + ]; + + final widths = List.filled(assets.length, tileHeight); + + if (isDynamicLayout) { + final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); + final meanAspectRatio = aspectRatios.sum / assets.length; + + // 1: mean width + // 0.5: width < mean - threshold + // 1.5: width > mean + threshold + final arConfiguration = aspectRatios.map((e) { + if (e - meanAspectRatio > 0.3) return 1.5; + if (e - meanAspectRatio < -0.3) return 0.5; + return 1.0; + }); + + // Normalize to get width distribution + final sum = arConfiguration.sum; + + int index = 0; + for (final ratio in arConfiguration) { + // Distribute the available width proportionally based on aspect ratio configuration + widths[index++] = ((ratio * assets.length) / sum) * tileHeight; + } + } + + return TimelineDragRegion( + child: TimelineRow( + height: tileHeight, + widths: widths, + spacing: spacing, + textDirection: Directionality.of(context), + children: children, + ), ); } } diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index 79ffb47e95..442d42d536 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -24,7 +24,7 @@ abstract class SegmentBuilder { Size size = kTimelineFixedTileExtent, double spacing = kTimelineSpacing, }) => RepaintBoundary( - child: FixedTimelineRow( + child: TimelineRow.fixed( dimension: size.height, spacing: spacing, textDirection: Directionality.of(context),