Compare commits

...

2 Commits

Author SHA1 Message Date
shenlong-tanwen
bd8ef9e1bf simplify _buildAssetRow 2025-11-19 18:14:44 +05:30
shenlong-tanwen
02de19df6d feat(mobile): dynamic layout in new timeline 2025-11-19 18:14:41 +05:30
3 changed files with 130 additions and 44 deletions

View File

@@ -1,27 +1,45 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget { class TimelineRow extends MultiChildRenderObjectWidget {
final double dimension; final double height;
final List<double> widths;
final double spacing; final double spacing;
final TextDirection textDirection; final TextDirection textDirection;
const FixedTimelineRow({ const TimelineRow({
super.key, super.key,
required this.dimension, required this.height,
required this.widths,
required this.spacing, required this.spacing,
required this.textDirection, required this.textDirection,
required super.children, required super.children,
}); });
factory TimelineRow.fixed({
required double dimension,
required double spacing,
required TextDirection textDirection,
required List<Widget> children,
}) => TimelineRow(
height: dimension,
widths: List.filled(children.length, dimension),
spacing: spacing,
textDirection: textDirection,
children: children,
);
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection); return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
} }
@override @override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension; renderObject.height = height;
renderObject.widths = widths;
renderObject.spacing = spacing; renderObject.spacing = spacing;
renderObject.textDirection = textDirection; renderObject.textDirection = textDirection;
} }
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension)); properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
} }
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> { RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({ RenderFixedRow({
List<RenderBox>? children, List<RenderBox>? children,
required double dimension, required double height,
required List<double> widths,
required double spacing, required double spacing,
required TextDirection textDirection, required TextDirection textDirection,
}) : _dimension = dimension, }) : _height = height,
_widths = widths,
_spacing = spacing, _spacing = spacing,
_textDirection = textDirection { _textDirection = textDirection {
addAll(children); addAll(children);
} }
double get dimension => _dimension; double get height => _height;
double _dimension; double _height;
set dimension(double value) { set height(double value) {
if (_dimension == value) return; if (_height == value) return;
_dimension = value; _height = value;
markNeedsLayout();
}
List<double> get widths => _widths;
List<double> _widths;
set widths(List<double> value) {
if (listEquals(_widths, value)) return;
_widths = value;
markNeedsLayout(); 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 @override
double computeMinIntrinsicWidth(double height) => intrinsicWidth; double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
double computeMaxIntrinsicWidth(double height) => intrinsicWidth; double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override @override
double computeMinIntrinsicHeight(double width) => dimension; double computeMinIntrinsicHeight(double width) => height;
@override @override
double computeMaxIntrinsicHeight(double width) => dimension; double computeMaxIntrinsicHeight(double width) => height;
@override @override
double? computeDistanceToActualBaseline(TextBaseline baseline) { double? computeDistanceToActualBaseline(TextBaseline baseline) {
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension)); properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing)); properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection)); properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
} }
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
return; return;
} }
// Use the entire width of the parent for the row. // Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, dimension); size = Size(constraints.maxWidth, height);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
final flipMainAxis = textDirection == TextDirection.rtl; final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); int childIndex = 0;
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
// Layout each child horizontally. // 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); child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData; final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset; childParentData.offset = Offset(currentX, 0);
offset += Offset(dx, 0);
child = childParentData.nextSibling; child = childParentData.nextSibling;
childIndex++;
if (child != null && childIndex < widths.length) {
final nextWidth = widths[childIndex];
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.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/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/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class FixedSegment extends Segment { class FixedSegment extends Segment {
final double tileHeight; final double tileHeight;
@@ -78,6 +81,7 @@ class FixedSegment extends Segment {
assetCount: numberOfAssets, assetCount: numberOfAssets,
tileHeight: tileHeight, tileHeight: tileHeight,
spacing: spacing, spacing: spacing,
columnCount: columnCount,
); );
} }
} }
@@ -87,24 +91,34 @@ class _FixedSegmentRow extends ConsumerWidget {
final int assetCount; final int assetCount;
final double tileHeight; final double tileHeight;
final double spacing; final double spacing;
final int columnCount;
const _FixedSegmentRow({ const _FixedSegmentRow({
required this.assetIndex, required this.assetIndex,
required this.assetCount, required this.assetCount,
required this.tileHeight, required this.tileHeight,
required this.spacing, required this.spacing,
required this.columnCount,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = ref.watch(
appSettingsServiceProvider.select((s) => s.getSetting(AppSettingsEnum.dynamicLayout)),
);
if (isScrubbing) { if (isScrubbing) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
if (timelineService.hasRange(assetIndex, assetCount)) { if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
timelineService,
isDynamicLayout,
);
} }
return FutureBuilder<List<BaseAsset>>( return FutureBuilder<List<BaseAsset>>(
@@ -113,7 +127,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context); 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); return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
} }
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) { Widget _buildAssetRow(
return FixedTimelineRow( BuildContext context,
dimension: tileHeight, List<BaseAsset> assets,
spacing: spacing, TimelineService timelineService,
textDirection: Directionality.of(context), bool isDynamicLayout,
children: [ ) {
for (int i = 0; i < assets.length; i++) final children = [
TimelineAssetIndexWrapper( 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, 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,
),
); );
} }
} }

View File

@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
Size size = kTimelineFixedTileExtent, Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing, double spacing = kTimelineSpacing,
}) => RepaintBoundary( }) => RepaintBoundary(
child: FixedTimelineRow( child: TimelineRow.fixed(
dimension: size.height, dimension: size.height,
spacing: spacing, spacing: spacing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),