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/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/timeline_drag_region.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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, ); } const FixedSegment.empty() : this( firstIndex: 0, lastIndex: 0, startOffset: 0, endOffset: 0, firstAssetIndex: 0, bucket: const Bucket(assetCount: 0), tileHeight: 1, columnCount: 0, headerExtent: 0, spacing: 0, header: HeaderType.none, ); } 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 timelineService = ref.read(timelineServiceProvider); try { final assets = timelineService.getAssets(assetIndex, assetCount); return _buildAssetRow(context, assets, timelineService); } catch (e) { return FutureBuilder>( future: timelineService.loadAssets(assetIndex, assetCount), builder: (context, snapshot) { return _buildAssetRow(context, snapshot.data, timelineService); }, ); } } Widget _buildAssetRow(BuildContext context, List? assets, TimelineService timelineService) { final assetIndex = this.assetIndex; return FixedTimelineRow( dimension: tileHeight, spacing: spacing, textDirection: Directionality.of(context), children: List.generate(assetCount, (i) { final curAssetIndex = assetIndex + i; return TimelineAssetIndexWrapper( // this key is intentionally generic to preserve the state of the widget and its subtree key: ValueKey(i.hashCode ^ timelineService.hashCode), assetIndex: curAssetIndex, segmentIndex: 0, // For simplicity, using 0 for now child: _AssetTileWidget(asset: assets?[i], assetIndex: curAssetIndex), ); }, growable: false), ); } } class _AssetTileWidget extends ConsumerWidget { final BaseAsset? asset; final int assetIndex; const _AssetTileWidget({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; ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(currentAssetNotifier.notifier).setAsset(asset); if (asset.isVideo || asset.isMotionPhoto) { ref.read(videoPlaybackValueProvider.notifier).reset(); ref.read(videoPlayerControlsProvider.notifier).pause(); } ctx.pushRoute( AssetViewerRoute( initialIndex: assetIndex, timelineService: ref.read(timelineServiceProvider), heroOffset: heroOffset, ), ); } } 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 asset = this.asset; return GestureDetector( onTap: () => lockSelection || asset == null ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), onLongPress: () => lockSelection || asset == null ? null : _handleOnLongPress(ref, asset), child: ThumbnailTile( asset, lockSelection: lockSelection, showStorageIndicator: showStorageIndicator, heroOffset: heroOffset, ), ); } }