2025-06-16 20:37:45 +05:30
|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.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';
|
2025-06-24 02:05:25 -05:00
|
|
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
2025-06-16 20:37:45 +05:30
|
|
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
2025-06-24 02:05:25 -05:00
|
|
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
2025-06-16 20:37:45 +05:30
|
|
|
|
|
|
|
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) {
|
|
|
|
index -= gridIndex;
|
|
|
|
if (index < 0) {
|
|
|
|
return startOffset;
|
|
|
|
}
|
|
|
|
return gridOffset + (mainAxisExtend * index);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
|
|
|
scrollOffset -= gridOffset;
|
|
|
|
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
|
|
|
return firstIndex;
|
|
|
|
}
|
|
|
|
final rowsAbove = (scrollOffset / mainAxisExtend).floor();
|
|
|
|
return gridIndex + rowsAbove;
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
|
|
|
scrollOffset -= gridOffset;
|
|
|
|
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
|
|
|
return firstIndex;
|
|
|
|
}
|
|
|
|
final firstRowBelow = (scrollOffset / mainAxisExtend).ceil();
|
|
|
|
return gridIndex + firstRowBelow - 1;
|
|
|
|
}
|
|
|
|
|
2025-06-24 02:05:25 -05:00
|
|
|
void _handleOnTap(WidgetRef ref, BaseAsset asset) {
|
2025-06-24 19:34:30 -05:00
|
|
|
final multiSelectState = ref.read(multiSelectProvider);
|
|
|
|
if (!multiSelectState.isEnabled) {
|
2025-06-24 02:05:25 -05:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
2025-06-24 19:34:30 -05:00
|
|
|
final multiSelectState = ref.read(multiSelectProvider);
|
|
|
|
if (multiSelectState.isEnabled) {
|
2025-06-24 02:05:25 -05:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
|
|
|
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
|
|
|
}
|
|
|
|
|
2025-06-16 20:37:45 +05:30
|
|
|
@override
|
|
|
|
Widget builder(BuildContext context, int index) {
|
2025-06-24 02:05:25 -05:00
|
|
|
final rowIndexInSegment = index - (firstIndex + 1);
|
|
|
|
final assetIndex = rowIndexInSegment * columnCount;
|
|
|
|
final assetCount = bucket.assetCount;
|
|
|
|
final numberOfAssets = math.min(columnCount, assetCount - assetIndex);
|
|
|
|
|
2025-06-16 20:37:45 +05:30
|
|
|
if (index == firstIndex) {
|
|
|
|
return TimelineHeader(
|
|
|
|
bucket: bucket,
|
|
|
|
header: header,
|
|
|
|
height: headerExtent,
|
2025-06-24 02:05:25 -05:00
|
|
|
assetOffset: firstAssetIndex,
|
2025-06-16 20:37:45 +05:30
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
|
|
|
|
}
|
|
|
|
|
2025-06-24 19:34:30 -05:00
|
|
|
Widget _buildRow(int assetIndex, int count) => RepaintBoundary(
|
|
|
|
child: Consumer(
|
|
|
|
builder: (ctx, ref, _) {
|
|
|
|
final isScrubbing =
|
|
|
|
ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
|
|
|
final timelineService = ref.read(timelineServiceProvider);
|
|
|
|
|
|
|
|
// Create stable callback references to prevent unnecessary rebuilds
|
|
|
|
onTap(BaseAsset asset) => _handleOnTap(ref, asset);
|
|
|
|
onLongPress(BaseAsset asset) => _handleOnLongPress(ref, asset);
|
|
|
|
|
|
|
|
// Timeline is being scrubbed, show placeholders
|
|
|
|
if (isScrubbing) {
|
|
|
|
return SegmentBuilder.buildPlaceholder(
|
|
|
|
ctx,
|
|
|
|
count,
|
|
|
|
size: Size.square(tileHeight),
|
|
|
|
spacing: spacing,
|
|
|
|
);
|
|
|
|
}
|
2025-06-16 20:37:45 +05:30
|
|
|
|
2025-06-24 19:34:30 -05:00
|
|
|
// Bucket is already loaded, show the assets
|
|
|
|
if (timelineService.hasRange(assetIndex, count)) {
|
|
|
|
final assets = timelineService.getAssets(assetIndex, count);
|
2025-06-24 02:05:25 -05:00
|
|
|
return _buildAssetRow(
|
2025-06-24 19:34:30 -05:00
|
|
|
ctx,
|
|
|
|
assets,
|
|
|
|
baseAssetIndex: assetIndex,
|
|
|
|
onTap: onTap,
|
|
|
|
onLongPress: onLongPress,
|
2025-06-24 02:05:25 -05:00
|
|
|
);
|
2025-06-24 19:34:30 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// Bucket is not loaded, show placeholders and load the bucket
|
|
|
|
return FutureBuilder(
|
|
|
|
future: timelineService.loadAssets(assetIndex, count),
|
|
|
|
builder: (ctxx, snap) {
|
|
|
|
if (snap.connectionState != ConnectionState.done) {
|
|
|
|
return SegmentBuilder.buildPlaceholder(
|
|
|
|
ctx,
|
|
|
|
count,
|
|
|
|
size: Size.square(tileHeight),
|
|
|
|
spacing: spacing,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return _buildAssetRow(
|
|
|
|
ctxx,
|
|
|
|
snap.requireData,
|
|
|
|
baseAssetIndex: assetIndex,
|
|
|
|
onTap: onTap,
|
|
|
|
onLongPress: onLongPress,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
2025-06-16 20:37:45 +05:30
|
|
|
);
|
|
|
|
|
2025-06-24 02:05:25 -05:00
|
|
|
Widget _buildAssetRow(
|
|
|
|
BuildContext context,
|
|
|
|
List<BaseAsset> assets, {
|
|
|
|
required void Function(BaseAsset) onTap,
|
|
|
|
required void Function(BaseAsset) onLongPress,
|
2025-06-24 19:34:30 -05:00
|
|
|
required int baseAssetIndex,
|
2025-06-24 02:05:25 -05:00
|
|
|
}) =>
|
2025-06-16 20:37:45 +05:30
|
|
|
FixedTimelineRow(
|
|
|
|
dimension: tileHeight,
|
|
|
|
spacing: spacing,
|
|
|
|
textDirection: Directionality.of(context),
|
|
|
|
children: List.generate(
|
|
|
|
assets.length,
|
2025-06-24 19:34:30 -05:00
|
|
|
(i) => _AssetTileWidget(
|
|
|
|
key: ValueKey(_generateUniqueKey(assets[i], baseAssetIndex + i)),
|
|
|
|
asset: assets[i],
|
|
|
|
onTap: onTap,
|
|
|
|
onLongPress: onLongPress,
|
2025-06-24 02:05:25 -05:00
|
|
|
),
|
2025-06-16 20:37:45 +05:30
|
|
|
),
|
|
|
|
);
|
2025-06-24 19:34:30 -05:00
|
|
|
|
|
|
|
/// Generates a unique key for an asset that handles different asset types
|
|
|
|
/// and prevents duplicate keys even when assets have the same name/timestamp
|
|
|
|
String _generateUniqueKey(BaseAsset asset, int assetIndex) {
|
|
|
|
// Try to get the most unique identifier based on asset type
|
2025-06-30 12:21:09 -05:00
|
|
|
if (asset is RemoteAsset) {
|
2025-06-24 19:34:30 -05:00
|
|
|
// For remote/merged assets, use the remote ID which is globally unique
|
|
|
|
return 'asset_${asset.id}';
|
|
|
|
} else if (asset is LocalAsset) {
|
|
|
|
// For local assets, use the local ID which should be unique per device
|
|
|
|
return 'local_${asset.id}';
|
|
|
|
} else {
|
|
|
|
// Fallback for any other BaseAsset implementation
|
|
|
|
// Use checksum if available for additional uniqueness
|
|
|
|
final checksum = asset.checksum;
|
|
|
|
if (checksum != null && checksum.isNotEmpty) {
|
|
|
|
return 'checksum_${checksum.hashCode}';
|
|
|
|
} else {
|
|
|
|
// Last resort: use global asset index + object hash for uniqueness
|
|
|
|
return 'fallback_${assetIndex}_${asset.hashCode}_${asset.createdAt.microsecondsSinceEpoch}';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _AssetTileWidget extends StatelessWidget {
|
|
|
|
final BaseAsset asset;
|
|
|
|
final void Function(BaseAsset) onTap;
|
|
|
|
final void Function(BaseAsset) onLongPress;
|
|
|
|
|
|
|
|
const _AssetTileWidget({
|
|
|
|
super.key,
|
|
|
|
required this.asset,
|
|
|
|
required this.onTap,
|
|
|
|
required this.onLongPress,
|
|
|
|
});
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return RepaintBoundary(
|
|
|
|
child: GestureDetector(
|
|
|
|
onTap: () => onTap(asset),
|
|
|
|
onLongPress: () => onLongPress(asset),
|
|
|
|
child: ThumbnailTile(asset),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2025-06-16 20:37:45 +05:30
|
|
|
}
|