Source Code added

This commit is contained in:
Fr4nz D13trich 2026-02-02 15:06:40 +01:00
parent 800376eafd
commit 9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions

View file

@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget {
final double dimension;
final double spacing;
final TextDirection textDirection;
const FixedTimelineRow({
super.key,
required this.dimension,
required this.spacing,
required this.textDirection,
required super.children,
});
@override
RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
}
@override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension;
renderObject.spacing = spacing;
renderObject.textDirection = textDirection;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
}
class _RowParentData extends ContainerBoxParentData<RenderBox> {}
class RenderFixedRow extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double dimension,
required double spacing,
required TextDirection textDirection,
}) : _dimension = dimension,
_spacing = spacing,
_textDirection = textDirection {
addAll(children);
}
double get dimension => _dimension;
double _dimension;
set dimension(double value) {
if (_dimension == value) return;
_dimension = value;
markNeedsLayout();
}
double get spacing => _spacing;
double _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
TextDirection get textDirection => _textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
if (_textDirection == value) return;
_textDirection = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _RowParentData) {
child.parentData = _RowParentData();
}
}
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
@override
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMinIntrinsicHeight(double width) => dimension;
@override
double computeMaxIntrinsicHeight(double width) => dimension;
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@override
void performLayout() {
RenderBox? child = firstChild;
if (child == null) {
size = constraints.smallest;
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));
final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
// Layout each child horizontally.
while (child != null) {
child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset;
offset += Offset(dx, 0);
child = childParentData.nextSibling;
}
}
}

View file

@ -0,0 +1,215 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
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/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';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
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';
class FixedSegment extends Segment {
final double tileHeight;
final int columnCount;
final double mainAxisExtend;
const FixedSegment({
required super.firstIndex,
required super.lastIndex,
required super.startOffset,
required super.endOffset,
required super.firstAssetIndex,
required super.bucket,
required this.tileHeight,
required this.columnCount,
required super.headerExtent,
required super.spacing,
required super.header,
}) : assert(tileHeight != 0),
mainAxisExtend = tileHeight + spacing;
@override
double indexToLayoutOffset(int index) {
final relativeIndex = index - gridIndex;
return relativeIndex < 0 ? startOffset : gridOffset + (mainAxisExtend * relativeIndex);
}
@override
int getMinChildIndexForScrollOffset(double scrollOffset) {
final adjustedOffset = scrollOffset - gridOffset;
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
return gridIndex + (adjustedOffset / mainAxisExtend).floor();
}
@override
int getMaxChildIndexForScrollOffset(double scrollOffset) {
final adjustedOffset = scrollOffset - gridOffset;
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
return gridIndex + (adjustedOffset / mainAxisExtend).ceil() - 1;
}
@override
Widget builder(BuildContext context, int index) {
final rowIndexInSegment = index - (firstIndex + 1);
final assetIndex = rowIndexInSegment * columnCount;
final assetCount = bucket.assetCount;
final numberOfAssets = math.min(columnCount, assetCount - assetIndex);
if (index == firstIndex) {
return TimelineHeader(bucket: bucket, header: header, height: headerExtent, assetOffset: firstAssetIndex);
}
return _FixedSegmentRow(
assetIndex: firstAssetIndex + assetIndex,
assetCount: numberOfAssets,
tileHeight: tileHeight,
spacing: spacing,
);
}
}
class _FixedSegmentRow extends ConsumerWidget {
final int assetIndex;
final int assetCount;
final double tileHeight;
final double spacing;
const _FixedSegmentRow({
required this.assetIndex,
required this.assetCount,
required this.tileHeight,
required this.spacing,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
if (isScrubbing) {
return _buildPlaceholder(context);
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
}
return FutureBuilder<List<BaseAsset>>(
future: timelineService.loadAssets(assetIndex, assetCount),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData, timelineService);
},
);
}
Widget _buildPlaceholder(BuildContext context) {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
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,
),
),
],
);
}
}
class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset;
final int assetIndex;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
} else {
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
AssetViewer.setAsset(ref, asset);
unawaited(
ctx.pushRoute(
AssetViewerRoute(
initialIndex: assetIndex,
timelineService: ref.read(timelineServiceProvider),
heroOffset: heroOffset,
currentAlbum: ref.read(currentRemoteAlbumProvider),
),
),
);
}
}
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.isEnabled || multiSelectState.forceEnable) {
return;
}
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
}
bool _getLockSelectionStatus(WidgetRef ref) {
final lockSelectionAssets = ref.read(multiSelectProvider.select((state) => state.lockedSelectionAssets));
if (lockSelectionAssets.isEmpty) {
return false;
}
return lockSelectionAssets.contains(asset);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
return RepaintBoundary(
child: GestureDetector(
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
child: ThumbnailTile(
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
),
),
);
}
}

View file

@ -0,0 +1,71 @@
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
class FixedSegmentBuilder extends SegmentBuilder {
final double tileHeight;
final int columnCount;
const FixedSegmentBuilder({
required super.buckets,
required this.tileHeight,
required this.columnCount,
super.spacing,
super.groupBy,
});
List<Segment> generate() {
final segments = <Segment>[];
int firstIndex = 0;
double startOffset = 0;
int assetIndex = 0;
DateTime? previousDate;
for (int i = 0; i < buckets.length; i++) {
final bucket = buckets[i];
final assetCount = bucket.assetCount;
final numberOfRows = (assetCount / columnCount).ceil();
final segmentCount = numberOfRows + 1;
final segmentFirstIndex = firstIndex;
firstIndex += segmentCount;
final segmentLastIndex = firstIndex - 1;
final timelineHeader = switch (groupBy) {
GroupAssetsBy.month => HeaderType.month,
GroupAssetsBy.day || GroupAssetsBy.auto =>
bucket is TimeBucket && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day,
GroupAssetsBy.none => HeaderType.none,
};
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
final segmentStartOffset = startOffset;
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
final segmentEndOffset = startOffset;
segments.add(
FixedSegment(
firstIndex: segmentFirstIndex,
lastIndex: segmentLastIndex,
startOffset: segmentStartOffset,
endOffset: segmentEndOffset,
firstAssetIndex: assetIndex,
bucket: bucket,
tileHeight: tileHeight,
columnCount: columnCount,
headerExtent: headerExtent,
spacing: spacing,
header: timelineHeader,
),
);
assetIndex += assetCount;
if (bucket is TimeBucket) {
previousDate = bucket.date;
}
}
return segments;
}
}