mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-26 08:12:33 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			212 lines
		
	
	
		
			6.1 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			212 lines
		
	
	
		
			6.1 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'dart:io';
 | |
| 
 | |
| import 'package:flutter/material.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/translate_extensions.dart';
 | |
| import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
 | |
| import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
 | |
| import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
 | |
| import 'package:immich_mobile/utils/async_mutex.dart';
 | |
| import 'package:immich_mobile/utils/debounce.dart';
 | |
| import 'package:immich_mobile/widgets/common/immich_toast.dart';
 | |
| import 'package:immich_mobile/widgets/map/map_theme_override.dart';
 | |
| import 'package:maplibre_gl/maplibre_gl.dart';
 | |
| 
 | |
| class CustomSourceProperties implements SourceProperties {
 | |
|   final Map<String, dynamic> data;
 | |
|   const CustomSourceProperties({required this.data});
 | |
| 
 | |
|   @override
 | |
|   Map<String, dynamic> toJson() {
 | |
|     return {
 | |
|       "type": "geojson",
 | |
|       "data": data,
 | |
|       // "cluster": true,
 | |
|       // "clusterRadius": 1,
 | |
|       // "clusterMinPoints": 5,
 | |
|       // "tolerance": 0.1,
 | |
|     };
 | |
|   }
 | |
| }
 | |
| 
 | |
| class DriftMap extends ConsumerStatefulWidget {
 | |
|   final LatLng? initialLocation;
 | |
| 
 | |
|   const DriftMap({super.key, this.initialLocation});
 | |
| 
 | |
|   @override
 | |
|   ConsumerState<DriftMap> createState() => _DriftMapState();
 | |
| }
 | |
| 
 | |
| class _DriftMapState extends ConsumerState<DriftMap> {
 | |
|   MapLibreMapController? mapController;
 | |
|   final _reloadMutex = AsyncMutex();
 | |
|   final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
 | |
| 
 | |
|   @override
 | |
|   void dispose() {
 | |
|     _debouncer.dispose();
 | |
|     super.dispose();
 | |
|   }
 | |
| 
 | |
|   void onMapCreated(MapLibreMapController controller) {
 | |
|     mapController = controller;
 | |
|   }
 | |
| 
 | |
|   Future<void> onMapReady() async {
 | |
|     final controller = mapController;
 | |
|     if (controller == null) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     await controller.addSource(
 | |
|       MapUtils.defaultSourceId,
 | |
|       const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
 | |
|     );
 | |
| 
 | |
|     if (Platform.isAndroid) {
 | |
|       await controller.addCircleLayer(
 | |
|         MapUtils.defaultSourceId,
 | |
|         MapUtils.defaultHeatMapLayerId,
 | |
|         const CircleLayerProperties(
 | |
|           circleRadius: 10,
 | |
|           circleColor: "rgba(150,86,34,0.7)",
 | |
|           circleBlur: 1.0,
 | |
|           circleOpacity: 0.7,
 | |
|           circleStrokeWidth: 0.1,
 | |
|           circleStrokeColor: "rgba(203,46,19,0.5)",
 | |
|           circleStrokeOpacity: 0.7,
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     if (Platform.isIOS) {
 | |
|       await controller.addHeatmapLayer(
 | |
|         MapUtils.defaultSourceId,
 | |
|         MapUtils.defaultHeatMapLayerId,
 | |
|         MapUtils.defaultHeatmapLayerProperties,
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     _debouncer.run(setBounds);
 | |
|     controller.addListener(onMapMoved);
 | |
|   }
 | |
| 
 | |
|   void onMapMoved() {
 | |
|     if (mapController!.isCameraMoving || !mounted) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     _debouncer.run(setBounds);
 | |
|   }
 | |
| 
 | |
|   Future<void> setBounds() async {
 | |
|     final controller = mapController;
 | |
|     if (controller == null || !mounted) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     final bounds = await controller.getVisibleRegion();
 | |
|     _reloadMutex.run(() async {
 | |
|       if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
 | |
|         final markers = await ref.read(mapMarkerProvider(bounds).future);
 | |
|         await reloadMarkers(markers);
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   Future<void> reloadMarkers(Map<String, dynamic> markers) async {
 | |
|     final controller = mapController;
 | |
|     if (controller == null || !mounted) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers);
 | |
|   }
 | |
| 
 | |
|   Future<void> onZoomToLocation() async {
 | |
|     final (location, error) = await MapUtils.checkPermAndGetLocation(context: context);
 | |
|     if (error != null) {
 | |
|       if (error == LocationPermission.unableToDetermine && context.mounted) {
 | |
|         ImmichToast.show(
 | |
|           context: context,
 | |
|           gravity: ToastGravity.BOTTOM,
 | |
|           toastType: ToastType.error,
 | |
|           msg: "map_cannot_get_user_location".t(context: context),
 | |
|         );
 | |
|       }
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     final controller = mapController;
 | |
|     if (controller != null && location != null) {
 | |
|       controller.animateCamera(
 | |
|         CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel),
 | |
|         duration: const Duration(milliseconds: 800),
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Stack(
 | |
|       children: [
 | |
|         _Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
 | |
|         _MyLocationButton(onZoomToLocation: onZoomToLocation),
 | |
|         const MapBottomSheet(),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _Map extends StatelessWidget {
 | |
|   final LatLng? initialLocation;
 | |
| 
 | |
|   const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady});
 | |
| 
 | |
|   final MapCreatedCallback onMapCreated;
 | |
| 
 | |
|   final VoidCallback onMapReady;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final initialLocation = this.initialLocation;
 | |
|     return MapThemeOverride(
 | |
|       mapBuilder: (style) => style.widgetWhen(
 | |
|         onData: (style) => MapLibreMap(
 | |
|           initialCameraPosition: initialLocation == null
 | |
|               ? const CameraPosition(target: LatLng(0, 0), zoom: 0)
 | |
|               : CameraPosition(target: initialLocation, zoom: MapUtils.mapZoomToAssetLevel),
 | |
|           styleString: style,
 | |
|           onMapCreated: onMapCreated,
 | |
|           onStyleLoadedCallback: onMapReady,
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _MyLocationButton extends StatelessWidget {
 | |
|   const _MyLocationButton({required this.onZoomToLocation});
 | |
| 
 | |
|   final VoidCallback onZoomToLocation;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Positioned(
 | |
|       right: 0,
 | |
|       bottom: context.padding.bottom + 16,
 | |
|       child: ElevatedButton(
 | |
|         onPressed: onZoomToLocation,
 | |
|         style: ElevatedButton.styleFrom(shape: const CircleBorder()),
 | |
|         child: const Icon(Icons.my_location),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |