You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-10 23:22:22 +02:00
feat: adds bottom sheet map and actions (#19726)
* reduce timeline rebuilds * feat: adds bottom sheet map and actions (#19692) * adds bottom sheet map and actions * PR feedbacks * only reload the asset viewer if asset is changed * styling tweak --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com> * rename singleton and remove event prefix * adds bottom sheet map and actions * PR feedbacks * refactor: use provider for viewer state * feat: adds top and bottom app bar * add safe area to bottom app bar * change app and bottom bar color * viewer - always have black background * use the full width for the bottom sheet on landscape as well * constraint the bottom sheet to not expand all the way * add padding for location details in landscape --------- 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:
@@ -7,7 +7,10 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.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';
|
||||
@@ -58,10 +61,10 @@ const double _kBottomSheetSnapExtent = 0.7;
|
||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
late PageController pageController;
|
||||
late DraggableScrollableController bottomSheetController;
|
||||
PersistentBottomSheetController? sheetCloseNotifier;
|
||||
PersistentBottomSheetController? sheetCloseController;
|
||||
// PhotoViewGallery takes care of disposing it's controllers
|
||||
PhotoViewControllerBase? viewController;
|
||||
StreamSubscription? _reloadSubscription;
|
||||
StreamSubscription? reloadSubscription;
|
||||
|
||||
late Platform platform;
|
||||
late PhotoViewControllerValue initialPhotoViewState;
|
||||
@@ -70,12 +73,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
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;
|
||||
BuildContext? scaffoldContext;
|
||||
|
||||
// Delayed operations that should be cancelled on disposal
|
||||
final List<Timer> _delayedOperations = [];
|
||||
@@ -90,8 +92,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_onAssetChanged(widget.initialIndex);
|
||||
});
|
||||
_reloadSubscription =
|
||||
EventStream.shared.listen<TimelineReloadEvent>(_onTimelineReload);
|
||||
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -99,36 +100,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
pageController.dispose();
|
||||
bottomSheetController.dispose();
|
||||
_cancelTimers();
|
||||
_reloadSubscription?.cancel();
|
||||
reloadSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTimelineReload(_) {
|
||||
setState(() {
|
||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
if (totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
final index = pageController.page?.round() ?? 0;
|
||||
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
// Do not reload / close the bottom sheet if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset.heroTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
_onAssetChanged(pageController.page!.round());
|
||||
sheetCloseNotifier?.close();
|
||||
});
|
||||
}
|
||||
bool get showingBottomSheet =>
|
||||
ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
|
||||
Color get backgroundColor {
|
||||
if (showingBottomSheet && !context.isDarkTheme) {
|
||||
return Colors.white;
|
||||
}
|
||||
return Colors.black.withAlpha(backgroundOpacity);
|
||||
final opacity =
|
||||
ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
return Colors.black.withAlpha(opacity);
|
||||
}
|
||||
|
||||
void _cancelTimers() {
|
||||
@@ -145,6 +127,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
|
||||
0.01;
|
||||
|
||||
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||
|
||||
Future<void> _precacheImage(int index) async {
|
||||
if (!mounted || index < 0 || index >= totalAssets) {
|
||||
return;
|
||||
@@ -247,16 +232,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
backgroundOpacity = 255;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
});
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
viewController?.animateMultiple(
|
||||
position: initialPhotoViewState.position,
|
||||
scale: initialPhotoViewState.scale,
|
||||
rotation: initialPhotoViewState.rotation,
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||
}
|
||||
|
||||
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
||||
@@ -277,18 +260,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
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) {
|
||||
@@ -301,66 +276,37 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openBottomSheet(BuildContext ctx) {
|
||||
setState(() {
|
||||
initialScale = viewController?.scale;
|
||||
viewController?.updateMultiple(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 _handleDragDown(BuildContext ctx, Offset delta) {
|
||||
const double dragRatio = 0.2;
|
||||
const double popThreshold = 75;
|
||||
|
||||
void _handleSheetClose() {
|
||||
setState(() {
|
||||
showingBottomSheet = false;
|
||||
sheetCloseNotifier = null;
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: initialScale);
|
||||
shouldPopOnDrag = false;
|
||||
hasDraggedDown = null;
|
||||
});
|
||||
}
|
||||
final distance = delta.distance;
|
||||
shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||
|
||||
void _snapBottomSheet() {
|
||||
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||
bottomSheetController.size < 0.4) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
isSnapping = true;
|
||||
bottomSheetController.animateTo(
|
||||
_kBottomSheetSnapExtent,
|
||||
duration: Durations.short3,
|
||||
curve: Curves.easeOut,
|
||||
|
||||
final backgroundOpacity =
|
||||
(255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
|
||||
viewController?.updateMultiple(
|
||||
position: initialPhotoViewState.position + delta,
|
||||
scale: updatedScale,
|
||||
);
|
||||
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
|
||||
}
|
||||
|
||||
void _onTapDown(_, __, ___) {
|
||||
if (!showingBottomSheet) {
|
||||
ref.read(assetViewerProvider.notifier).toggleControls();
|
||||
}
|
||||
}
|
||||
|
||||
bool _onNotification(Notification delta) {
|
||||
// Ignore notifications when user dragging the asset
|
||||
if (dragInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delta is DraggableScrollableNotification) {
|
||||
_handleDraggableNotification(delta);
|
||||
}
|
||||
@@ -375,50 +321,117 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _handleDraggableNotification(DraggableScrollableNotification delta) {
|
||||
final verticalOffset = (context.height * delta.extent) -
|
||||
(context.height * _kBottomSheetMinimumExtent);
|
||||
final currentExtent = delta.extent;
|
||||
final isDraggingDown = currentExtent < previousExtent;
|
||||
previousExtent = currentExtent;
|
||||
// Closes the bottom sheet if the user is dragging down
|
||||
if (isDraggingDown && delta.extent < 0.5) {
|
||||
if (dragInProgress) {
|
||||
blockGestures = true;
|
||||
}
|
||||
sheetCloseController?.close();
|
||||
}
|
||||
|
||||
// If the asset is being dragged down, we do not want to update the asset position again
|
||||
if (dragInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
|
||||
// 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 _onEvent(Event event) {
|
||||
if (event is TimelineReloadEvent) {
|
||||
_onTimelineReload(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is ViewerOpenBottomSheetEvent) {
|
||||
final extent = _kBottomSheetMinimumExtent + 0.3;
|
||||
_openBottomSheet(scaffoldContext!, extent: extent);
|
||||
final offset = _getVerticalOffsetForBottomSheet(extent);
|
||||
viewController?.position = Offset(0, -offset);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||
const double dragRatio = 0.2;
|
||||
const double popThreshold = 75;
|
||||
void _onTimelineReload(_) {
|
||||
setState(() {
|
||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
if (totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
final distance = delta.distance;
|
||||
final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||
final index = pageController.page?.round() ?? 0;
|
||||
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
// Do not reload / close the bottom sheet if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
_onAssetChanged(pageController.page!.round());
|
||||
sheetCloseController?.close();
|
||||
});
|
||||
}
|
||||
|
||||
final newBackgroundOpacity =
|
||||
(255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||
|
||||
viewController?.updateMultiple(
|
||||
position: initialPhotoViewState.position + delta,
|
||||
scale: updatedScale,
|
||||
void _openBottomSheet(
|
||||
BuildContext ctx, {
|
||||
double extent = _kBottomSheetMinimumExtent,
|
||||
}) {
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||
initialScale = viewController?.scale;
|
||||
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
|
||||
previousExtent = _kBottomSheetMinimumExtent;
|
||||
sheetCloseController = showBottomSheet(
|
||||
context: ctx,
|
||||
sheetAnimationStyle: AnimationStyle(
|
||||
duration: Durations.short4,
|
||||
reverseDuration: Durations.short2,
|
||||
),
|
||||
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||
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: extent,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (shouldPopOnDrag != newShouldPopOnDrag ||
|
||||
backgroundOpacity != newBackgroundOpacity) {
|
||||
setState(() {
|
||||
shouldPopOnDrag = newShouldPopOnDrag;
|
||||
backgroundOpacity = newBackgroundOpacity;
|
||||
});
|
||||
sheetCloseController?.closed.then((_) => _handleSheetClose());
|
||||
}
|
||||
|
||||
void _handleSheetClose() {
|
||||
viewController?.animateMultiple(position: Offset.zero);
|
||||
viewController?.updateMultiple(scale: initialScale);
|
||||
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||
sheetCloseController = null;
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholderBuilder(
|
||||
@@ -443,6 +456,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||
scaffoldContext ??= ctx;
|
||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||
final size = Size(ctx.width, ctx.height);
|
||||
|
||||
@@ -458,6 +472,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onTapDown: _onTapDown,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
@@ -477,13 +492,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Rebuild the widget when the asset viewer state changes
|
||||
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||
|
||||
// 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 PopScope(
|
||||
onPopInvokedWithResult: _onPop,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black.withAlpha(backgroundOpacity),
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
@@ -499,6 +522,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomBar(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user