1
0
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:
shenlong
2025-07-05 00:38:06 +05:30
committed by GitHub
parent 4a2cf28882
commit 73733370a2
18 changed files with 622 additions and 155 deletions

View File

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