mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 02:02:34 -04:00 
			
		
		
		
	* chore(dep): update auto_route * chore: rebase main --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
		
			
				
	
	
		
			408 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			408 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:math';
 | |
| import 'package:auto_route/auto_route.dart';
 | |
| import 'package:collection/collection.dart';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.dart';
 | |
| import 'package:fluttertoast/fluttertoast.dart';
 | |
| import 'package:geolocator/geolocator.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 | |
| import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | |
| import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
 | |
| import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
 | |
| import 'package:immich_mobile/modules/map/models/map_event.model.dart';
 | |
| import 'package:immich_mobile/modules/map/models/map_marker.dart';
 | |
| import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
 | |
| import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
 | |
| import 'package:immich_mobile/modules/map/utils/map_utils.dart';
 | |
| import 'package:immich_mobile/modules/map/widgets/map_app_bar.dart';
 | |
| import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart';
 | |
| import 'package:immich_mobile/modules/map/widgets/map_bottom_sheet.dart';
 | |
| import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
 | |
| import 'package:immich_mobile/modules/map/widgets/positioned_asset_marker_icon.dart';
 | |
| import 'package:immich_mobile/routing/router.dart';
 | |
| import 'package:immich_mobile/shared/models/asset.dart';
 | |
| import 'package:immich_mobile/shared/providers/db.provider.dart';
 | |
| import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | |
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 | |
| import 'package:immich_mobile/utils/debounce.dart';
 | |
| import 'package:maplibre_gl/maplibre_gl.dart';
 | |
| 
 | |
| @RoutePage()
 | |
| class MapPage extends HookConsumerWidget {
 | |
|   const MapPage({super.key});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final mapController = useRef<MaplibreMapController?>(null);
 | |
|     final markers = useRef<List<MapMarker>>([]);
 | |
|     final markersInBounds = useRef<List<MapMarker>>([]);
 | |
|     final bottomSheetStreamController = useStreamController<MapEvent>();
 | |
|     final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null);
 | |
|     final assetsDebouncer = useDebouncer();
 | |
|     final isLoading = useProcessingOverlay();
 | |
|     final scrollController = useScrollController();
 | |
|     final markerDebouncer =
 | |
|         useDebouncer(interval: const Duration(milliseconds: 800));
 | |
|     final selectedAssets = useValueNotifier<Set<Asset>>({});
 | |
|     const mapZoomToAssetLevel = 12.0;
 | |
| 
 | |
|     // updates the markersInBounds value with the map markers that are visible in the current
 | |
|     // map camera bounds
 | |
|     Future<void> updateAssetsInBounds() async {
 | |
|       // Guard map not created
 | |
|       if (mapController.value == null) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       final bounds = await mapController.value!.getVisibleRegion();
 | |
|       final inBounds = markers.value
 | |
|           .where(
 | |
|             (m) =>
 | |
|                 bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
 | |
|           )
 | |
|           .toList();
 | |
|       // Notify bottom sheet to update asset grid only when there are new assets
 | |
|       if (markersInBounds.value.length != inBounds.length) {
 | |
|         bottomSheetStreamController.add(
 | |
|           MapAssetsInBoundsUpdated(
 | |
|             inBounds.map((e) => e.assetRemoteId).toList(),
 | |
|           ),
 | |
|         );
 | |
|       }
 | |
|       markersInBounds.value = inBounds;
 | |
|     }
 | |
| 
 | |
|     // removes all sources and layers and re-adds them with the updated markers
 | |
|     Future<void> reloadLayers() async {
 | |
|       if (mapController.value != null) {
 | |
|         mapController.value!.reloadAllLayersForMarkers(markers.value);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     Future<void> loadMarkers() async {
 | |
|       try {
 | |
|         isLoading.value = true;
 | |
|         markers.value = await ref.read(mapMarkersProvider.future);
 | |
|         assetsDebouncer.run(updateAssetsInBounds);
 | |
|         reloadLayers();
 | |
|       } finally {
 | |
|         isLoading.value = false;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     useEffect(
 | |
|       () {
 | |
|         loadMarkers();
 | |
|         return null;
 | |
|       },
 | |
|       [],
 | |
|     );
 | |
| 
 | |
|     // Refetch markers when map state is changed
 | |
|     ref.listen(mapStateNotifierProvider, (_, current) {
 | |
|       if (current.shouldRefetchMarkers) {
 | |
|         markerDebouncer.run(() {
 | |
|           ref.invalidate(mapMarkersProvider);
 | |
|           // Reset marker
 | |
|           selectedMarker.value = null;
 | |
|           loadMarkers();
 | |
|           ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
 | |
|         });
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     // updates the selected markers position based on the current map camera
 | |
|     Future<void> updateAssetMarkerPosition(
 | |
|       MapMarker marker, {
 | |
|       bool shouldAnimate = true,
 | |
|     }) async {
 | |
|       final assetPoint =
 | |
|           await mapController.value!.toScreenLocation(marker.latLng);
 | |
|       selectedMarker.value = _AssetMarkerMeta(
 | |
|         point: assetPoint,
 | |
|         marker: marker,
 | |
|         shouldAnimate: shouldAnimate,
 | |
|       );
 | |
|       (assetPoint, marker, shouldAnimate);
 | |
|     }
 | |
| 
 | |
|     // finds the nearest asset marker from the tap point and store it as the selectedMarker
 | |
|     Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
 | |
|       // Guard map not created
 | |
|       if (mapController.value == null) {
 | |
|         return;
 | |
|       }
 | |
|       final latlngBound =
 | |
|           await mapController.value!.getBoundsFromPoint(point, 50);
 | |
|       final marker = markersInBounds.value.firstWhereOrNull(
 | |
|         (m) =>
 | |
|             latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
 | |
|       );
 | |
| 
 | |
|       if (marker != null) {
 | |
|         updateAssetMarkerPosition(marker);
 | |
|       } else {
 | |
|         // If no asset was previously selected and no new asset is available, close the bottom sheet
 | |
|         if (selectedMarker.value == null) {
 | |
|           bottomSheetStreamController.add(MapCloseBottomSheet());
 | |
|         }
 | |
|         selectedMarker.value = null;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void onMapCreated(MaplibreMapController controller) async {
 | |
|       mapController.value = controller;
 | |
|       controller.addListener(() {
 | |
|         if (controller.isCameraMoving && selectedMarker.value != null) {
 | |
|           updateAssetMarkerPosition(
 | |
|             selectedMarker.value!.marker,
 | |
|             shouldAnimate: false,
 | |
|           );
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     Future<void> onMarkerTapped() async {
 | |
|       final assetId = selectedMarker.value?.marker.assetRemoteId;
 | |
|       if (assetId == null) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       final asset = await ref.read(dbProvider).assets.getByRemoteId(assetId);
 | |
|       if (asset == null) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       context.pushRoute(
 | |
|         GalleryViewerRoute(
 | |
|           initialIndex: 0,
 | |
|           loadAsset: (index) => asset,
 | |
|           totalAssets: 1,
 | |
|           heroOffset: 0,
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     /// BOTTOM SHEET CALLBACKS
 | |
| 
 | |
|     Future<void> onMapMoved() async {
 | |
|       assetsDebouncer.run(updateAssetsInBounds);
 | |
|     }
 | |
| 
 | |
|     void onBottomSheetScrolled(String assetRemoteId) {
 | |
|       final assetMarker = markersInBounds.value
 | |
|           .firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
 | |
|       if (assetMarker != null) {
 | |
|         updateAssetMarkerPosition(assetMarker);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void onZoomToAsset(String assetRemoteId) {
 | |
|       final assetMarker = markersInBounds.value
 | |
|           .firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
 | |
|       if (mapController.value != null && assetMarker != null) {
 | |
|         // Offset the latitude a little to show the marker just above the viewports center
 | |
|         final offset = context.isMobile ? 0.02 : 0;
 | |
|         final latlng = LatLng(
 | |
|           assetMarker.latLng.latitude - offset,
 | |
|           assetMarker.latLng.longitude,
 | |
|         );
 | |
|         mapController.value!.animateCamera(
 | |
|           CameraUpdate.newLatLngZoom(latlng, mapZoomToAssetLevel),
 | |
|           duration: const Duration(milliseconds: 800),
 | |
|         );
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void onZoomToLocation() async {
 | |
|       final location = await MapUtils.checkPermAndGetLocation(context);
 | |
|       if (location.$2 != null) {
 | |
|         if (location.$2 == LocationPermission.unableToDetermine &&
 | |
|             context.mounted) {
 | |
|           ImmichToast.show(
 | |
|             context: context,
 | |
|             gravity: ToastGravity.BOTTOM,
 | |
|             toastType: ToastType.error,
 | |
|             msg: "map_cannot_get_user_location".tr(),
 | |
|           );
 | |
|         }
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if (mapController.value != null && location.$1 != null) {
 | |
|         mapController.value!.animateCamera(
 | |
|           CameraUpdate.newLatLngZoom(
 | |
|             LatLng(location.$1!.latitude, location.$1!.longitude),
 | |
|             mapZoomToAssetLevel,
 | |
|           ),
 | |
|           duration: const Duration(milliseconds: 800),
 | |
|         );
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     void onAssetsSelected(bool selected, Set<Asset> selection) {
 | |
|       selectedAssets.value = selected ? selection : {};
 | |
|     }
 | |
| 
 | |
|     return MapThemeOveride(
 | |
|       mapBuilder: (style) => context.isMobile
 | |
|           // Single-column
 | |
|           ? Scaffold(
 | |
|               extendBodyBehindAppBar: true,
 | |
|               appBar: MapAppBar(selectedAssets: selectedAssets),
 | |
|               body: Stack(
 | |
|                 children: [
 | |
|                   _MapWithMarker(
 | |
|                     style: style,
 | |
|                     selectedMarker: selectedMarker,
 | |
|                     onMapCreated: onMapCreated,
 | |
|                     onMapMoved: onMapMoved,
 | |
|                     onMapClicked: onMarkerClicked,
 | |
|                     onStyleLoaded: reloadLayers,
 | |
|                     onMarkerTapped: onMarkerTapped,
 | |
|                   ),
 | |
|                   // Should be a part of the body and not scaffold::bottomsheet for the
 | |
|                   // location button to be hit testable
 | |
|                   MapBottomSheet(
 | |
|                     mapEventStream: bottomSheetStreamController.stream,
 | |
|                     onGridAssetChanged: onBottomSheetScrolled,
 | |
|                     onZoomToAsset: onZoomToAsset,
 | |
|                     onAssetsSelected: onAssetsSelected,
 | |
|                     onZoomToLocation: onZoomToLocation,
 | |
|                     selectedAssets: selectedAssets,
 | |
|                   ),
 | |
|                 ],
 | |
|               ),
 | |
|             )
 | |
|           // Two-pane
 | |
|           : Row(
 | |
|               children: [
 | |
|                 Expanded(
 | |
|                   child: Scaffold(
 | |
|                     extendBodyBehindAppBar: true,
 | |
|                     appBar: MapAppBar(selectedAssets: selectedAssets),
 | |
|                     body: Stack(
 | |
|                       children: [
 | |
|                         _MapWithMarker(
 | |
|                           style: style,
 | |
|                           selectedMarker: selectedMarker,
 | |
|                           onMapCreated: onMapCreated,
 | |
|                           onMapMoved: onMapMoved,
 | |
|                           onMapClicked: onMarkerClicked,
 | |
|                           onStyleLoaded: reloadLayers,
 | |
|                           onMarkerTapped: onMarkerTapped,
 | |
|                         ),
 | |
|                         Positioned(
 | |
|                           right: 0,
 | |
|                           bottom: 30,
 | |
|                           child: ElevatedButton(
 | |
|                             onPressed: onZoomToLocation,
 | |
|                             style: ElevatedButton.styleFrom(
 | |
|                               shape: const CircleBorder(),
 | |
|                             ),
 | |
|                             child: const Icon(Icons.my_location),
 | |
|                           ),
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|                 Expanded(
 | |
|                   child: LayoutBuilder(
 | |
|                     builder: (ctx, constraints) => MapAssetGrid(
 | |
|                       controller: scrollController,
 | |
|                       mapEventStream: bottomSheetStreamController.stream,
 | |
|                       onGridAssetChanged: onBottomSheetScrolled,
 | |
|                       onZoomToAsset: onZoomToAsset,
 | |
|                       onAssetsSelected: onAssetsSelected,
 | |
|                       selectedAssets: selectedAssets,
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _AssetMarkerMeta {
 | |
|   final Point<num> point;
 | |
|   final MapMarker marker;
 | |
|   final bool shouldAnimate;
 | |
| 
 | |
|   const _AssetMarkerMeta({
 | |
|     required this.point,
 | |
|     required this.marker,
 | |
|     required this.shouldAnimate,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   String toString() =>
 | |
|       '_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
 | |
| }
 | |
| 
 | |
| class _MapWithMarker extends StatelessWidget {
 | |
|   final AsyncValue<String> style;
 | |
|   final MapCreatedCallback onMapCreated;
 | |
|   final OnCameraIdleCallback onMapMoved;
 | |
|   final OnMapClickCallback onMapClicked;
 | |
|   final OnStyleLoadedCallback onStyleLoaded;
 | |
|   final Function()? onMarkerTapped;
 | |
|   final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
 | |
| 
 | |
|   const _MapWithMarker({
 | |
|     required this.style,
 | |
|     required this.onMapCreated,
 | |
|     required this.onMapMoved,
 | |
|     required this.onMapClicked,
 | |
|     required this.onStyleLoaded,
 | |
|     required this.selectedMarker,
 | |
|     this.onMarkerTapped,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return LayoutBuilder(
 | |
|       builder: (ctx, constraints) => SizedBox(
 | |
|         height: constraints.maxHeight,
 | |
|         width: constraints.maxWidth,
 | |
|         child: Stack(
 | |
|           children: [
 | |
|             style.widgetWhen(
 | |
|               onData: (style) => MaplibreMap(
 | |
|                 initialCameraPosition:
 | |
|                     const CameraPosition(target: LatLng(0, 0)),
 | |
|                 styleString: style,
 | |
|                 // This is needed to update the selectedMarker's position on map camera updates
 | |
|                 // The changes are notified through the mapController ValueListener which is added in [onMapCreated]
 | |
|                 trackCameraPosition: true,
 | |
|                 onMapCreated: onMapCreated,
 | |
|                 onCameraIdle: onMapMoved,
 | |
|                 onMapClick: onMapClicked,
 | |
|                 onStyleLoadedCallback: onStyleLoaded,
 | |
|                 tiltGesturesEnabled: false,
 | |
|                 dragEnabled: false,
 | |
|                 myLocationEnabled: false,
 | |
|                 attributionButtonPosition: AttributionButtonPosition.TopRight,
 | |
|               ),
 | |
|             ),
 | |
|             ValueListenableBuilder(
 | |
|               valueListenable: selectedMarker,
 | |
|               builder: (ctx, value, _) => value != null
 | |
|                   ? PositionedAssetMarkerIcon(
 | |
|                       point: value.point,
 | |
|                       assetRemoteId: value.marker.assetRemoteId,
 | |
|                       durationInMilliseconds: value.shouldAnimate ? 100 : 0,
 | |
|                       onTap: onMarkerTapped,
 | |
|                     )
 | |
|                   : const SizedBox.shrink(),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |