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:
shenlong 2025-07-02 23:54:37 +05:30 committed by GitHub
parent ec603a008c
commit 7855974a29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1867 additions and 490 deletions

View file

@ -0,0 +1,473 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart';
@RoutePage()
class AssetViewerPage extends StatelessWidget {
final int initialIndex;
final TimelineService timelineService;
const AssetViewerPage({
super.key,
required this.initialIndex,
required this.timelineService,
});
@override
Widget build(BuildContext context) {
// This is necessary to ensure that the timeline service is available
// since the Timeline and AssetViewer are on different routes / Widget subtrees.
return ProviderScope(
overrides: [timelineServiceProvider.overrideWithValue(timelineService)],
child: AssetViewer(initialIndex: initialIndex),
);
}
}
class AssetViewer extends ConsumerStatefulWidget {
final int initialIndex;
final Platform? platform;
const AssetViewer({
super.key,
required this.initialIndex,
this.platform,
});
@override
ConsumerState createState() => _AssetViewerState();
}
const double _kBottomSheetMinimumExtent = 0.4;
const double _kBottomSheetSnapExtent = 0.7;
class _AssetViewerState extends ConsumerState<AssetViewer> {
late PageController pageController;
late DraggableScrollableController bottomSheetController;
PersistentBottomSheetController? sheetCloseNotifier;
// PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController;
late Platform platform;
late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown;
bool isSnapping = false;
bool blockGestures = false;
bool dragInProgress = false;
bool shouldPopOnDrag = false;
bool showingBottomSheet = false;
double? initialScale;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
int backgroundOpacity = 255;
// Delayed operations that should be cancelled on disposal
final List<Timer> _delayedOperations = [];
@override
void initState() {
super.initState();
pageController = PageController(initialPage: widget.initialIndex);
platform = widget.platform ?? const LocalPlatform();
totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback((_) {
_onAssetChanged(widget.initialIndex);
});
}
@override
void dispose() {
pageController.dispose();
bottomSheetController.dispose();
_cancelTimers();
super.dispose();
}
Color get backgroundColor {
if (showingBottomSheet && !context.isDarkTheme) {
return Colors.white;
}
return Colors.black.withAlpha(backgroundOpacity);
}
void _cancelTimers() {
for (final timer in _delayedOperations) {
timer.cancel();
}
_delayedOperations.clear();
}
// This is used to calculate the scale of the asset when the bottom sheet is showing.
// It is a small increment to ensure that the asset is slightly zoomed in when the
// bottom sheet is showing, which emulates the zoom effect.
double get _getScaleForBottomSheet =>
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
0.01;
Future<void> _precacheImage(int index) async {
if (!mounted || index < 0 || index >= totalAssets) {
return;
}
final asset = ref.read(timelineServiceProvider).getAsset(index);
final screenSize = Size(context.width, context.height);
// Precache both thumbnail and full image for smooth transitions
unawaited(
Future.wait([
precacheImage(
getThumbnailImageProvider(asset: asset, size: screenSize),
context,
onError: (_, __) {},
),
precacheImage(
getFullImageProvider(asset, size: screenSize),
context,
onError: (_, __) {},
),
]),
);
}
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
unawaited(ref.read(timelineServiceProvider).preCacheAssets(index));
_cancelTimers();
// This will trigger the pre-caching of adjacent assets ensuring
// that they are ready when the user navigates to them.
final timer = Timer(Durations.medium4, () {
// Check if widget is still mounted before proceeding
if (!mounted) return;
for (final offset in [-1, 1]) {
unawaited(_precacheImage(index + offset));
}
});
_delayedOperations.add(timer);
}
void _onPageBuild(PhotoViewControllerBase controller) {
viewController ??= controller;
if (showingBottomSheet) {
final verticalOffset = (context.height * bottomSheetController.size) -
(context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
}
}
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index);
viewController = controller;
// If the bottom sheet is showing, we need to adjust scale the asset to
// emulate the zoom effect
if (showingBottomSheet) {
initialScale = controller?.scale;
controller?.scale = _getScaleForBottomSheet;
}
}
void _onDragStart(
_,
DragStartDetails details,
PhotoViewControllerValue value,
PhotoViewScaleStateController scaleStateController,
) {
dragDownPosition = details.localPosition;
initialPhotoViewState = value;
final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering;
if (!showingBottomSheet && isZoomed) {
blockGestures = true;
}
}
void _onDragEnd(BuildContext ctx, _, __) {
dragInProgress = false;
if (shouldPopOnDrag) {
// Dismiss immediately without state updates to avoid rebuilds
ctx.maybePop();
return;
}
// Do not reset the state if the bottom sheet is showing
if (showingBottomSheet) {
_snapBottomSheet();
return;
}
// If the gestures are blocked, do not reset the state
if (blockGestures) {
blockGestures = false;
return;
}
setState(() {
shouldPopOnDrag = false;
hasDraggedDown = null;
backgroundOpacity = 255;
viewController?.animateMultiple(
position: initialPhotoViewState.position,
scale: initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation,
);
});
}
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
if (blockGestures) {
return;
}
dragInProgress = true;
final delta = details.localPosition - dragDownPosition;
hasDraggedDown ??= delta.dy > 0;
if (!hasDraggedDown! || showingBottomSheet) {
_handleDragUp(ctx, delta);
return;
}
_handleDragDown(ctx, delta);
}
void _handleDragUp(BuildContext ctx, Offset delta) {
const double openThreshold = 50;
const double closeThreshold = 25;
final position = initialPhotoViewState.position + Offset(0, delta.dy);
final distanceToOrigin = position.distance;
if (showingBottomSheet && distanceToOrigin < closeThreshold) {
// Prevents the user from dragging the bottom sheet further down
blockGestures = true;
sheetCloseNotifier?.close();
return;
}
viewController?.updateMultiple(position: position);
// Moves the bottom sheet when the asset is being dragged up
if (showingBottomSheet && bottomSheetController.isAttached) {
final centre = (ctx.height * _kBottomSheetMinimumExtent);
bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height);
}
if (distanceToOrigin > openThreshold && !showingBottomSheet) {
_openBottomSheet(ctx);
}
}
void _openBottomSheet(BuildContext ctx) {
setState(() {
initialScale = viewController?.scale;
viewController?.animateMultiple(scale: _getScaleForBottomSheet);
showingBottomSheet = true;
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseNotifier = showBottomSheet(
context: ctx,
sheetAnimationStyle: AnimationStyle(
duration: Duration.zero,
reverseDuration: Duration.zero,
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
),
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
builder: (_) {
return NotificationListener<Notification>(
onNotification: _onNotification,
child: AssetDetailBottomSheet(
controller: bottomSheetController,
initialChildSize: _kBottomSheetMinimumExtent,
),
);
},
);
sheetCloseNotifier?.closed.then((_) => _handleSheetClose());
});
}
void _handleSheetClose() {
setState(() {
showingBottomSheet = false;
sheetCloseNotifier = null;
viewController?.animateMultiple(
position: Offset.zero,
scale: initialScale,
);
shouldPopOnDrag = false;
hasDraggedDown = null;
});
}
void _snapBottomSheet() {
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
}
isSnapping = true;
bottomSheetController.animateTo(
_kBottomSheetSnapExtent,
duration: Durations.short3,
curve: Curves.easeOut,
);
}
bool _onNotification(Notification delta) {
// Ignore notifications when user dragging the asset
if (dragInProgress) {
return false;
}
if (delta is DraggableScrollableNotification) {
_handleDraggableNotification(delta);
}
// Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after
// the isSnapping guard is to prevent the notification from recursively handling the
// notification, eventually resulting in a heap overflow
if (!isSnapping && delta is ScrollEndNotification) {
_snapBottomSheet();
}
return false;
}
void _handleDraggableNotification(DraggableScrollableNotification delta) {
final verticalOffset = (context.height * delta.extent) -
(context.height * _kBottomSheetMinimumExtent);
// Moves the asset when the bottom sheet is being dragged
if (verticalOffset > 0) {
viewController?.position = Offset(0, -verticalOffset);
}
final currentExtent = delta.extent;
final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent
if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) {
sheetCloseNotifier?.close();
}
}
void _handleDragDown(BuildContext ctx, Offset delta) {
const double dragRatio = 0.2;
const double popThreshold = 75;
final distance = delta.distance;
final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale;
if (initialPhotoViewState.scale != null) {
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
}
final newBackgroundOpacity =
(255 * (1.0 - (scaleReduction / dragRatio))).round();
viewController?.updateMultiple(
position: initialPhotoViewState.position + delta,
scale: updatedScale,
);
if (shouldPopOnDrag != newShouldPopOnDrag ||
backgroundOpacity != newBackgroundOpacity) {
setState(() {
shouldPopOnDrag = newShouldPopOnDrag;
backgroundOpacity = newBackgroundOpacity;
});
}
}
Widget _placeholderBuilder(
BuildContext ctx,
ImageChunkEvent? progress,
int index,
) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: Thumbnail(
asset: asset,
fit: BoxFit.contain,
size: Size(
ctx.width,
ctx.height,
),
),
);
}
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
final size = Size(ctx.width, ctx.height);
final imageProvider = getFullImageProvider(asset, size: size);
return PhotoViewGalleryPageOptions(
imageProvider: imageProvider,
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
filterQuality: FilterQuality.high,
tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999,
minScale: PhotoViewComputedScale.contained * 0.999,
disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
errorBuilder: (_, __, ___) => Container(
width: ctx.width,
height: ctx.height,
color: backgroundColor,
child: Thumbnail(
asset: asset,
fit: BoxFit.contain,
size: size,
),
),
);
}
@override
Widget build(BuildContext context) {
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
// Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable
return Scaffold(
backgroundColor: Colors.black.withAlpha(backgroundOpacity),
body: PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: platform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics() // Use heavy physics for Android
,
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
);
}
}

View file

@ -0,0 +1,199 @@
import 'package:easy_localization/easy_localization.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/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
const _kSeparator = '';
class AssetDetailBottomSheet extends BaseBottomSheet {
const AssetDetailBottomSheet({
super.controller,
super.initialChildSize,
super.key,
}) : super(
actions: const [],
slivers: const [_AssetDetailBottomSheet()],
minChildSize: 0.1,
maxChildSize: 1.0,
expand: false,
shouldCloseOnMinExtent: false,
resizeOnScroll: false,
);
}
class _AssetDetailBottomSheet extends ConsumerWidget {
const _AssetDetailBottomSheet();
String _getDateTime(BuildContext ctx, BaseAsset asset) {
final dateTime = asset.createdAt.toLocal();
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
return '$date$_kSeparator$time';
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height ?? exifInfo?.height;
final width = asset.width ?? exifInfo?.width;
final resolution =
(width != null && height != null) ? "$width x $height" : null;
final fileSize =
exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
return switch ((fileSize, resolution)) {
(null, null) => '',
(String fileSize, null) => fileSize,
(null, String resolution) => resolution,
(String fileSize, String resolution) =>
'$fileSize$_kSeparator$resolution',
};
}
String? _getCameraInfoTitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
return switch ((exifInfo.make, exifInfo.model)) {
(null, null) => null,
(String make, null) => make,
(null, String model) => model,
(String make, String model) => '$make $model',
};
}
String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
if (exifInfo == null) {
return null;
}
final fNumber =
exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null;
final exposureTime =
exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null;
final focalLength =
exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null;
final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null;
return [fNumber, exposureTime, focalLength, iso]
.where((spec) => spec != null && spec.isNotEmpty)
.join(_kSeparator);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
return SliverList.list(
children: [
// Asset Date and Time
_SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
// Details header
_SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color,
fontWeight: FontWeight.w600,
),
),
// File info
_SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.w600),
leading: Icon(
asset.isImage ? Icons.image_outlined : Icons.videocam_outlined,
size: 30,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(200),
),
),
// Camera info
if (cameraTitle != null)
_SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.w600),
leading: Icon(
Icons.camera_outlined,
size: 30,
color: context.textTheme.labelLarge?.color,
),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(200),
),
),
],
);
}
}
class _SheetTile extends StatelessWidget {
final String title;
final Widget? leading;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
const _SheetTile({
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
});
@override
Widget build(BuildContext context) {
final Widget titleWidget;
if (leading == null) {
titleWidget = LimitedBox(
maxWidth: double.infinity,
child: Text(title, style: titleStyle),
);
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15),
child: Text(title, style: titleStyle),
);
}
final Widget? subtitleWidget;
if (leading == null && subtitle != null) {
subtitleWidget = Text(subtitle!, style: subtitleStyle);
} else if (leading != null && subtitle != null) {
subtitleWidget = Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(subtitle!, style: subtitleStyle),
);
} else {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: titleWidget,
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
class BaseBottomSheet extends ConsumerStatefulWidget {
@ -74,10 +75,7 @@ class _BaseDraggableScrollableSheetState
clipBehavior: Clip.antiAlias,
elevation: 6.0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
margin: const EdgeInsets.symmetric(horizontal: 0),
child: CustomScrollView(
@ -86,17 +84,22 @@ class _BaseDraggableScrollableSheetState
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(height: 16),
const SizedBox(height: 10),
const _DragHandle(),
const SizedBox(height: 16),
SizedBox(
height: 120,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: widget.actions,
const SizedBox(height: 14),
if (widget.actions.isNotEmpty)
SizedBox(
height: 80,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: widget.actions,
),
),
),
if (widget.actions.isNotEmpty) const SizedBox(height: 14),
if (widget.actions.isNotEmpty)
const Divider(indent: 20, endIndent: 20),
if (widget.actions.isNotEmpty) const SizedBox(height: 14),
],
),
),
@ -118,7 +121,7 @@ class _DragHandle extends StatelessWidget {
height: 6,
width: 32,
decoration: BoxDecoration(
color: context.themeData.dividerColor,
color: context.themeData.dividerColor.lighten(amount: 0.6),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
);

View file

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class FullImage extends StatelessWidget {
const FullImage(
this.asset, {
required this.size,
this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(),
super.key,
});
final BaseAsset asset;
final Size size;
final Widget? placeholder;
final BoxFit fit;
@override
Widget build(BuildContext context) {
final provider = getFullImageProvider(asset, size: size);
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 100),
placeholderBuilder: placeholder != null ? (_) => placeholder! : null,
image: provider,
width: size.width,
height: size.height,
fit: fit,
errorBuilder: (context, error, stackTrace) {
provider.evict();
return const Icon(Icons.image_not_supported_outlined, size: 32);
},
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
ImageProvider getFullImageProvider(
BaseAsset asset, {
Size size = const Size(1080, 1920),
}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
provider = LocalFullImageProvider(asset: asset as LocalAsset, size: size);
} else {
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
} else if (asset is RemoteAsset) {
assetId = asset.id;
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId);
}
return provider;
}
ImageProvider getThumbnailImageProvider({
BaseAsset? asset,
String? remoteId,
Size size = const Size.square(256),
}) {
assert(
asset != null || remoteId != null,
'Either asset or remoteId must be provided',
);
if (remoteId != null) {
return RemoteThumbProvider(assetId: remoteId);
}
if (_shouldUseLocalAsset(asset!)) {
return LocalThumbProvider(asset: asset as LocalAsset, size: size);
}
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
assetId = asset.remoteId!;
} else if (asset is RemoteAsset) {
assetId = asset.id;
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
return RemoteThumbProvider(assetId: assetId);
}
bool _shouldUseLocalAsset(BaseAsset asset) =>
asset is LocalAsset &&
(!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));

View file

@ -0,0 +1,241 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
import 'package:logging/logging.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final AssetMediaRepository _assetMediaRepository =
const AssetMediaRepository();
final CacheManager? cacheManager;
final LocalAsset asset;
final Size size;
const LocalThumbProvider({
required this.asset,
this.size = const Size.square(kTimelineFixedTileExtent),
this.cacheManager,
});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
LocalThumbProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode),
scale: 1.0,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
],
);
}
Future<Codec> _codec(
LocalThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
) async {
final cacheKey =
'${key.asset.id}-${key.asset.updatedAt}-${key.size.width}x${key.size.height}';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer =
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
return decode(buffer);
} catch (_) {}
}
final thumbnailBytes =
await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size);
if (thumbnailBytes == null) {
PaintingBinding.instance.imageCache.evict(key);
throw StateError(
"Loading thumb for local photo ${key.asset.name} failed",
);
}
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
unawaited(cache.putFile(cacheKey, thumbnailBytes));
return decode(buffer);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalThumbProvider) {
return asset.id == other.asset.id &&
asset.updatedAt == other.asset.updatedAt;
}
return false;
}
@override
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
}
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final AssetMediaRepository _assetMediaRepository =
const AssetMediaRepository();
final StorageRepository _storageRepository = const StorageRepository();
final LocalAsset asset;
final Size size;
const LocalFullImageProvider({
required this.asset,
required this.size,
});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
LocalFullImageProvider key,
ImageDecoderCallback decode,
) {
return MultiImageStreamCompleter(
codec: _codec(key, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(asset.name);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<Codec> _codec(
LocalFullImageProvider key,
ImageDecoderCallback decode,
) async* {
try {
switch (key.asset.type) {
case AssetType.image:
yield* _decodeProgressive(key, decode);
break;
case AssetType.video:
final codec = await _getThumbnailCodec(key, decode);
if (codec == null) {
throw StateError("Failed to load preview for ${key.asset.name}");
}
yield codec;
break;
case AssetType.other:
case AssetType.audio:
throw StateError('Unsupported asset type ${key.asset.type}');
}
} catch (error, stack) {
Logger('ImmichLocalImageProvider')
.severe('Error loading local image ${key.asset.name}', error, stack);
throw const ImageLoadingException(
'Could not load image from local storage',
);
}
}
Future<Codec?> _getThumbnailCodec(
LocalFullImageProvider key,
ImageDecoderCallback decode,
) async {
final thumbBytes =
await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size);
if (thumbBytes == null) {
return null;
}
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
return decode(buffer);
}
Stream<Codec> _decodeProgressive(
LocalFullImageProvider key,
ImageDecoderCallback decode,
) async* {
final file = await _storageRepository.getFileForAsset(key.asset);
if (file == null) {
throw StateError("Opening file for asset ${key.asset.name} failed");
}
final fileSize = await file.length();
final devicePixelRatio =
PlatformDispatcher.instance.views.first.devicePixelRatio;
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
if (isProgressive) {
try {
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
final size = Size(
(key.size.width * progressiveMultiplier).clamp(256, 1024),
(key.size.height * progressiveMultiplier).clamp(256, 1024),
);
final mediumThumb =
await _assetMediaRepository.getThumbnail(key.asset.id, size: size);
if (mediumThumb != null) {
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
yield await decode(mediumBuffer);
}
} catch (_) {}
}
// Load original only when the file is smaller or if the user wants to load original images
// Or load a slightly larger image for progressive loading
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
final size = Size(
(key.size.width * progressiveMultiplier).clamp(512, 2048),
(key.size.height * progressiveMultiplier).clamp(512, 2048),
);
final highThumb =
await _assetMediaRepository.getThumbnail(key.asset.id, size: size);
if (highThumb != null) {
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
yield await decode(highBuffer);
}
return;
}
final buffer = await ImmutableBuffer.fromFilePath(file.path);
yield await decode(buffer);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalFullImageProvider) {
return asset.id == other.asset.id &&
asset.updatedAt == other.asset.updatedAt &&
size == other.size;
}
return false;
}
@override
int get hashCode =>
asset.id.hashCode ^ asset.updatedAt.hashCode ^ size.hashCode;
}

View file

@ -1,95 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final AssetMediaRepository _assetMediaRepository =
const AssetMediaRepository();
final CacheManager? cacheManager;
final LocalAsset asset;
final double height;
final double width;
LocalThumbProvider({
required this.asset,
this.height = kTimelineFixedTileExtent,
this.width = kTimelineFixedTileExtent,
this.cacheManager,
});
@override
Future<LocalThumbProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
LocalThumbProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode),
scale: 1.0,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<LocalAsset>('Asset', key.asset),
],
);
}
Future<Codec> _codec(
LocalThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
) async {
final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer =
await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
return await decode(buffer);
} catch (_) {}
}
final thumbnailBytes = await _assetMediaRepository.getThumbnail(
key.asset.id,
size: Size(key.width, key.height),
);
if (thumbnailBytes == null) {
PaintingBinding.instance.imageCache.evict(key);
throw StateError(
"Loading thumb for local photo ${key.asset.name} failed",
);
}
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
unawaited(cache.putFile(cacheKey, thumbnailBytes));
return decode(buffer);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is LocalThumbProvider) {
return asset.id == other.asset.id &&
asset.updatedAt == other.asset.updatedAt;
}
return false;
}
@override
int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode;
}

View file

@ -0,0 +1,142 @@
import 'dart:async';
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
final String assetId;
final CacheManager? cacheManager;
const RemoteThumbProvider({
required this.assetId,
this.cacheManager,
});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
RemoteThumbProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkController = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode, chunkController),
scale: 1.0,
chunkEvents: chunkController.stream,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
}
Future<Codec> _codec(
RemoteThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async {
final preview = getThumbnailUrlForRemoteId(
key.assetId,
);
return ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkController,
).whenComplete(chunkController.close);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
final String assetId;
final CacheManager? cacheManager;
const RemoteFullImageProvider({
required this.assetId,
this.cacheManager,
});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
RemoteFullImageProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
}
Stream<Codec> _codec(
RemoteFullImageProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async* {
yield await ImageLoader.loadImageFromCache(
getPreviewUrlForRemoteId(key.assetId),
cache: cache,
decode: decode,
chunkEvents: chunkController,
);
if (AppSetting.get(Setting.loadOriginal)) {
yield await ImageLoader.loadImageFromCache(
getOriginalUrlForRemoteId(key.assetId),
cache: cache,
decode: decode,
chunkEvents: chunkController,
);
}
await chunkController.close();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View file

@ -1,80 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
final String assetId;
final double height;
final double width;
final CacheManager? cacheManager;
const RemoteThumbProvider({
required this.assetId,
this.height = kTimelineFixedTileExtent,
this.width = kTimelineFixedTileExtent,
this.cacheManager,
});
@override
Future<RemoteThumbProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
RemoteThumbProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
final chunkController = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _codec(key, cache, decode, chunkController),
scale: 1.0,
chunkEvents: chunkController.stream,
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
}
Future<Codec> _codec(
RemoteThumbProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkController,
) async {
final preview = getThumbnailUrlForRemoteId(
key.assetId,
);
return ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkController,
).whenComplete(chunkController.close);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
@ -25,49 +24,12 @@ class Thumbnail extends StatelessWidget {
final Size size;
final BoxFit fit;
static ImageProvider imageProvider({
BaseAsset? asset,
String? remoteId,
Size size = const Size.square(256),
}) {
assert(
asset != null || remoteId != null,
'Either asset or remoteId must be provided',
);
if (remoteId != null) {
return RemoteThumbProvider(
assetId: remoteId,
height: size.height,
width: size.width,
);
}
if (asset is LocalAsset) {
return LocalThumbProvider(
asset: asset,
height: size.height,
width: size.width,
);
}
if (asset is RemoteAsset) {
return RemoteThumbProvider(
assetId: asset.id,
height: size.height,
width: size.width,
);
}
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
@override
Widget build(BuildContext context) {
final thumbHash =
asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
final provider =
imageProvider(asset: asset, remoteId: remoteId, size: size);
getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size);
return OctoImage.fromSet(
image: provider,

View file

@ -59,10 +59,13 @@ class ThumbnailTile extends ConsumerWidget {
child: Stack(
children: [
Positioned.fill(
child: Thumbnail(
asset: asset,
fit: fit,
size: size,
child: Hero(
tag: asset.heroTag,
child: Thumbnail(
asset: asset,
fit: fit,
size: size,
),
),
),
if (asset.isVideo)

View file

@ -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),
),
);