1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-07 06:16:05 +02:00
Files
immich/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart
shenlong 7855974a29 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>
2025-07-02 18:24:37 +00:00

474 lines
15 KiB
Dart

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