You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-07-16 07:24:40 +02:00
feat(mobile): sqlite asset viewer (#19552)
* add full image provider and refactor thumb providers * photo_view updates * wip: asset-viewer * fix controller dispose on page change * wip: bottom sheet * fix interactions * more bottomsheet changes * generate schema * PR feedback * refactor asset viewer * never rotate and fix background on page change * use photoview as the loading builder * precache after delay * claude: optimizing rebuild of image provider * claude: optimizing image decoding and caching * use proper cache for new full size image providers * chore: load local HEIC fullsize for iOS * make controller callbacks nullable * remove imageprovider cache * do not handle drag gestures when zoomed * use loadOriginal setting for HEIC / larger images * preload assets outside timer * never use same controllers in photo-view gallery * fix: cannot scroll down once swipe with bottom sheet --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@ -12,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
||||
import 'package:immich_mobile/providers/haptic_feedback.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;
|
||||
@ -35,50 +37,24 @@ class FixedSegment extends Segment {
|
||||
|
||||
@override
|
||||
double indexToLayoutOffset(int index) {
|
||||
index -= gridIndex;
|
||||
if (index < 0) {
|
||||
return startOffset;
|
||||
}
|
||||
return gridOffset + (mainAxisExtend * index);
|
||||
final relativeIndex = index - gridIndex;
|
||||
return relativeIndex < 0
|
||||
? startOffset
|
||||
: gridOffset + (mainAxisExtend * relativeIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
int getMinChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= gridOffset;
|
||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
||||
return firstIndex;
|
||||
}
|
||||
final rowsAbove = (scrollOffset / mainAxisExtend).floor();
|
||||
return gridIndex + rowsAbove;
|
||||
final adjustedOffset = scrollOffset - gridOffset;
|
||||
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
|
||||
return gridIndex + (adjustedOffset / mainAxisExtend).floor();
|
||||
}
|
||||
|
||||
@override
|
||||
int getMaxChildIndexForScrollOffset(double scrollOffset) {
|
||||
scrollOffset -= gridOffset;
|
||||
if (!scrollOffset.isFinite || scrollOffset < 0) {
|
||||
return firstIndex;
|
||||
}
|
||||
final firstRowBelow = (scrollOffset / mainAxisExtend).ceil();
|
||||
return gridIndex + firstRowBelow - 1;
|
||||
}
|
||||
|
||||
void _handleOnTap(WidgetRef ref, BaseAsset asset) {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (!multiSelectState.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
}
|
||||
|
||||
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (multiSelectState.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
final adjustedOffset = scrollOffset - gridOffset;
|
||||
if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex;
|
||||
return gridIndex + (adjustedOffset / mainAxisExtend).ceil() - 1;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -97,132 +73,128 @@ class FixedSegment extends Segment {
|
||||
);
|
||||
}
|
||||
|
||||
return _buildRow(firstAssetIndex + assetIndex, numberOfAssets);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// Bucket is already loaded, show the assets
|
||||
if (timelineService.hasRange(assetIndex, count)) {
|
||||
final assets = timelineService.getAssets(assetIndex, count);
|
||||
return _buildAssetRow(
|
||||
ctx,
|
||||
assets,
|
||||
baseAssetIndex: assetIndex,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
List<BaseAsset> assets, {
|
||||
required void Function(BaseAsset) onTap,
|
||||
required void Function(BaseAsset) onLongPress,
|
||||
required int baseAssetIndex,
|
||||
}) =>
|
||||
FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: List.generate(
|
||||
assets.length,
|
||||
(i) => _AssetTileWidget(
|
||||
key: ValueKey(_generateUniqueKey(assets[i], baseAssetIndex + i)),
|
||||
asset: assets[i],
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 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
|
||||
if (asset is RemoteAsset) {
|
||||
// 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}';
|
||||
}
|
||||
}
|
||||
return _FixedSegmentRow(
|
||||
assetIndex: firstAssetIndex + assetIndex,
|
||||
assetCount: numberOfAssets,
|
||||
tileHeight: tileHeight,
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTileWidget extends StatelessWidget {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return SegmentBuilder.buildPlaceholder(
|
||||
context,
|
||||
assetCount,
|
||||
size: Size.square(tileHeight),
|
||||
spacing: spacing,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
_AssetTileWidget(
|
||||
key: ValueKey(assets[i].heroTag),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetTileWidget extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final void Function(BaseAsset) onTap;
|
||||
final void Function(BaseAsset) onLongPress;
|
||||
final int assetIndex;
|
||||
|
||||
const _AssetTileWidget({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.assetIndex,
|
||||
});
|
||||
|
||||
void _handleOnTap(
|
||||
BuildContext ctx,
|
||||
WidgetRef ref,
|
||||
int assetIndex,
|
||||
BaseAsset asset,
|
||||
) {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (!multiSelectState.isEnabled) {
|
||||
ctx.pushRoute(
|
||||
AssetViewerRoute(
|
||||
initialIndex: assetIndex,
|
||||
timelineService: ref.read(timelineServiceProvider),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
}
|
||||
|
||||
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (multiSelectState.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap(asset),
|
||||
onLongPress: () => onLongPress(asset),
|
||||
onTap: () => _handleOnTap(context, ref, assetIndex, asset),
|
||||
onLongPress: () => _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(asset),
|
||||
),
|
||||
);
|
||||
|
Reference in New Issue
Block a user