You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	refactor(mobile): maplibre (#6087)
* chore: maplibre gl pubspec * refactor(wip): maplibre for maps * refactor(wip): dual pane + location button * chore: remove flutter_map and deps * refactor(wip): map zoom to location * refactor: location picker * open gallery_viewer on marker tap * remove detectScaleGesture param * test: debounce and throttle * chore: rename get location method * feat(mobile): Adds gps locator to map prompt for easy geolocation (#6282) * Refactored get gps coords * Use var for linter's sake, should handle errors better * Cleanup * Fix linter issues * chore(dep): update maplibre to official lib --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Joshua Herrera <joshua.herrera227@gmail.com>
This commit is contained in:
		| @@ -253,7 +253,7 @@ | |||||||
|   "map_no_assets_in_bounds": "No photos in this area", |   "map_no_assets_in_bounds": "No photos in this area", | ||||||
|   "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", |   "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", | ||||||
|   "map_no_location_permission_title": "Location Permission denied", |   "map_no_location_permission_title": "Location Permission denied", | ||||||
|   "map_settings_dark_mode": "Dark mode", |   "map_settings_theme_settings": "Map Theme", | ||||||
|   "map_settings_date_range_option_all": "All", |   "map_settings_date_range_option_all": "All", | ||||||
|   "map_settings_date_range_option_day": "Past 24 hours", |   "map_settings_date_range_option_day": "Past 24 hours", | ||||||
|   "map_settings_date_range_option_days": "Past {} days", |   "map_settings_date_range_option_days": "Past {} days", | ||||||
|   | |||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 23 KiB | 
| @@ -28,6 +28,10 @@ PODS: | |||||||
|     - Flutter |     - Flutter | ||||||
|   - isar_flutter_libs (1.0.0): |   - isar_flutter_libs (1.0.0): | ||||||
|     - Flutter |     - Flutter | ||||||
|  |   - MapLibre (5.14.0-pre3) | ||||||
|  |   - maplibre_gl (0.0.1): | ||||||
|  |     - Flutter | ||||||
|  |     - MapLibre (= 5.14.0-pre3) | ||||||
|   - package_info_plus (0.4.5): |   - package_info_plus (0.4.5): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - path_provider_foundation (0.0.1): |   - path_provider_foundation (0.0.1): | ||||||
| @@ -71,6 +75,7 @@ DEPENDENCIES: | |||||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) |   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||||
|   - integration_test (from `.symlinks/plugins/integration_test/ios`) |   - integration_test (from `.symlinks/plugins/integration_test/ios`) | ||||||
|   - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) |   - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) | ||||||
|  |   - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) | ||||||
|   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) |   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) | ||||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) |   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | ||||||
|   - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) |   - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) | ||||||
| @@ -86,6 +91,7 @@ DEPENDENCIES: | |||||||
| SPEC REPOS: | SPEC REPOS: | ||||||
|   trunk: |   trunk: | ||||||
|     - FMDB |     - FMDB | ||||||
|  |     - MapLibre | ||||||
|     - ReachabilitySwift |     - ReachabilitySwift | ||||||
|     - SAMKeychain |     - SAMKeychain | ||||||
|     - Toast |     - Toast | ||||||
| @@ -115,6 +121,8 @@ EXTERNAL SOURCES: | |||||||
|     :path: ".symlinks/plugins/integration_test/ios" |     :path: ".symlinks/plugins/integration_test/ios" | ||||||
|   isar_flutter_libs: |   isar_flutter_libs: | ||||||
|     :path: ".symlinks/plugins/isar_flutter_libs/ios" |     :path: ".symlinks/plugins/isar_flutter_libs/ios" | ||||||
|  |   maplibre_gl: | ||||||
|  |     :path: ".symlinks/plugins/maplibre_gl/ios" | ||||||
|   package_info_plus: |   package_info_plus: | ||||||
|     :path: ".symlinks/plugins/package_info_plus/ios" |     :path: ".symlinks/plugins/package_info_plus/ios" | ||||||
|   path_provider_foundation: |   path_provider_foundation: | ||||||
| @@ -152,6 +160,8 @@ SPEC CHECKSUMS: | |||||||
|   image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 |   image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 | ||||||
|   integration_test: 13825b8a9334a850581300559b8839134b124670 |   integration_test: 13825b8a9334a850581300559b8839134b124670 | ||||||
|   isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 |   isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 | ||||||
|  |   MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef | ||||||
|  |   maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 | ||||||
|   package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 |   package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 | ||||||
|   path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 |   path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 | ||||||
|   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 |   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 | ||||||
|   | |||||||
| @@ -96,3 +96,9 @@ extension AssetListExtension on Iterable<Asset> { | |||||||
|     return this; |     return this; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | extension SortedByProperty<T> on Iterable<T> { | ||||||
|  |   Iterable<T> sortedByField(Comparable Function(T e) key) { | ||||||
|  |     return sorted((a, b) => key(a).compareTo(key(b))); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,67 +0,0 @@ | |||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_map/flutter_map.dart'; |  | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
| import 'dart:math' as math; |  | ||||||
|  |  | ||||||
| extension MoveByBounds on MapController { |  | ||||||
|   // TODO: Remove this in favor of built-in method when upgrading flutter_map to 5.0.0 |  | ||||||
|   LatLng? centerBoundsWithPadding( |  | ||||||
|     LatLng coordinates, |  | ||||||
|     Offset offset, { |  | ||||||
|     double? zoomLevel, |  | ||||||
|   }) { |  | ||||||
|     const crs = Epsg3857(); |  | ||||||
|     final oldCenterPt = crs.latLngToPoint(coordinates, zoomLevel ?? zoom); |  | ||||||
|     final mapCenterPoint = _rotatePoint( |  | ||||||
|       oldCenterPt, |  | ||||||
|       oldCenterPt - CustomPoint(offset.dx, offset.dy), |  | ||||||
|     ); |  | ||||||
|     return crs.pointToLatLng(mapCenterPoint, zoomLevel ?? zoom); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   CustomPoint<double> _rotatePoint( |  | ||||||
|     CustomPoint<double> mapCenter, |  | ||||||
|     CustomPoint<double> point, { |  | ||||||
|     bool counterRotation = true, |  | ||||||
|   }) { |  | ||||||
|     final counterRotationFactor = counterRotation ? -1 : 1; |  | ||||||
|  |  | ||||||
|     final m = Matrix4.identity() |  | ||||||
|       ..translate(mapCenter.x, mapCenter.y) |  | ||||||
|       ..rotateZ(degToRadian(rotation) * counterRotationFactor) |  | ||||||
|       ..translate(-mapCenter.x, -mapCenter.y); |  | ||||||
|  |  | ||||||
|     final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y)); |  | ||||||
|  |  | ||||||
|     return CustomPoint(tp.dx, tp.dy); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   double getTapThresholdForZoomLevel() { |  | ||||||
|     const scale = [ |  | ||||||
|       25000000, |  | ||||||
|       15000000, |  | ||||||
|       8000000, |  | ||||||
|       4000000, |  | ||||||
|       2000000, |  | ||||||
|       1000000, |  | ||||||
|       500000, |  | ||||||
|       250000, |  | ||||||
|       100000, |  | ||||||
|       50000, |  | ||||||
|       25000, |  | ||||||
|       15000, |  | ||||||
|       8000, |  | ||||||
|       4000, |  | ||||||
|       2000, |  | ||||||
|       1000, |  | ||||||
|       500, |  | ||||||
|       250, |  | ||||||
|       100, |  | ||||||
|       50, |  | ||||||
|       25, |  | ||||||
|       10, |  | ||||||
|       5, |  | ||||||
|     ]; |  | ||||||
|     return scale[math.max(0, math.min(20, zoom.round() + 2))].toDouble() / 6; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										20
									
								
								mobile/lib/extensions/latlngbounds_extension.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								mobile/lib/extensions/latlngbounds_extension.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
|  | extension WithinBounds on LatLngBounds { | ||||||
|  |   /// Checks whether [point] is inside bounds | ||||||
|  |   bool contains(LatLng point) { | ||||||
|  |     final sw = point; | ||||||
|  |     final ne = point; | ||||||
|  |     return containsBounds(LatLngBounds(southwest: sw, northeast: ne)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Checks whether [bounds] is contained inside bounds | ||||||
|  |   bool containsBounds(LatLngBounds bounds) { | ||||||
|  |     final sw = bounds.southwest; | ||||||
|  |     final ne = bounds.northeast; | ||||||
|  |     return (sw.latitude >= southwest.latitude) && | ||||||
|  |         (ne.latitude <= northeast.latitude) && | ||||||
|  |         (sw.longitude >= southwest.longitude) && | ||||||
|  |         (ne.longitude <= northeast.longitude); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										71
									
								
								mobile/lib/extensions/maplibrecontroller_extensions.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								mobile/lib/extensions/maplibrecontroller_extensions.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | |||||||
|  | import 'dart:math'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/models/map_marker.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/utils/map_utils.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
|  | extension MapMarkers on MaplibreMapController { | ||||||
|  |   Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async { | ||||||
|  |     return addSource( | ||||||
|  |       MapUtils.defaultSourceId, | ||||||
|  |       GeojsonSourceProperties( | ||||||
|  |         data: MapUtils.generateGeoJsonForMarkers(markers.toList()), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> reloadAllLayersForMarkers(List<MapMarker> markers) async { | ||||||
|  |     // !! Make sure to remove layers before sources else the native | ||||||
|  |     // maplibre library would crash when removing the source saying that | ||||||
|  |     // the source is still in use | ||||||
|  |     final existingLayers = await getLayerIds(); | ||||||
|  |     if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) { | ||||||
|  |       await removeLayer(MapUtils.defaultHeatMapLayerId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final existingSources = await getSourceIds(); | ||||||
|  |     if (existingSources.contains(MapUtils.defaultSourceId)) { | ||||||
|  |       await removeSource(MapUtils.defaultSourceId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     await addGeoJSONSourceForMarkers(markers); | ||||||
|  |  | ||||||
|  |     await addHeatmapLayer( | ||||||
|  |       MapUtils.defaultSourceId, | ||||||
|  |       MapUtils.defaultHeatMapLayerId, | ||||||
|  |       MapUtils.defaultHeatMapLayerProperties, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<Symbol?> addMarkerAtLatLng(LatLng centre) async { | ||||||
|  |     // no marker is displayed if asset-path is incorrect | ||||||
|  |     try { | ||||||
|  |       final ByteData bytes = await rootBundle.load("assets/location-pin.png"); | ||||||
|  |       await addImage("mapMarker", bytes.buffer.asUint8List()); | ||||||
|  |       return addSymbol( | ||||||
|  |         SymbolOptions( | ||||||
|  |           geometry: centre, | ||||||
|  |           iconImage: "mapMarker", | ||||||
|  |           iconSize: 0.15, | ||||||
|  |           iconAnchor: "bottom", | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } finally { | ||||||
|  |       // no-op | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<LatLngBounds> getBoundsFromPoint( | ||||||
|  |     Point<double> point, | ||||||
|  |     double distance, | ||||||
|  |   ) async { | ||||||
|  |     final southWestPx = Point(point.x - distance, point.y + distance); | ||||||
|  |     final northEastPx = Point(point.x + distance, point.y - distance); | ||||||
|  |  | ||||||
|  |     final southWest = await toLatLng(southWestPx); | ||||||
|  |     final northEast = await toLatLng(northEastPx); | ||||||
|  |  | ||||||
|  |     return LatLngBounds(southwest: southWest, northeast: northEast); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,7 +6,7 @@ part of 'current_asset.provider.dart'; | |||||||
| // RiverpodGenerator | // RiverpodGenerator | ||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
| 
 | 
 | ||||||
| String _$currentAssetHash() => r'018d9f936991c48f06c11bf7e72130bba25806e2'; | String _$currentAssetHash() => r'2def10ea594152c984ae2974d687ab6856d7bdd0'; | ||||||
| 
 | 
 | ||||||
| /// See also [CurrentAsset]. | /// See also [CurrentAsset]. | ||||||
| @ProviderFor(CurrentAsset) | @ProviderFor(CurrentAsset) | ||||||
|   | |||||||
| @@ -2,19 +2,18 @@ import 'dart:io'; | |||||||
|  |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_map/flutter_map.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:immich_mobile/extensions/asset_extensions.dart'; | import 'package:immich_mobile/extensions/asset_extensions.dart'; | ||||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
| import 'package:immich_mobile/extensions/duration_extensions.dart'; | import 'package:immich_mobile/extensions/duration_extensions.dart'; | ||||||
| import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; | import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; | import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; | ||||||
| import 'package:immich_mobile/shared/models/asset.dart'; | import 'package:immich_mobile/shared/models/asset.dart'; | ||||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||||
| import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | ||||||
| import 'package:immich_mobile/utils/selection_handlers.dart'; | import 'package:immich_mobile/utils/selection_handlers.dart'; | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
| import 'package:immich_mobile/utils/bytes_units.dart'; | import 'package:immich_mobile/utils/bytes_units.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
| import 'package:url_launcher/url_launcher.dart'; | import 'package:url_launcher/url_launcher.dart'; | ||||||
|  |  | ||||||
| class ExifBottomSheet extends HookConsumerWidget { | class ExifBottomSheet extends HookConsumerWidget { | ||||||
| @@ -92,26 +91,14 @@ class ExifBottomSheet extends HookConsumerWidget { | |||||||
|         child: LayoutBuilder( |         child: LayoutBuilder( | ||||||
|           builder: (context, constraints) { |           builder: (context, constraints) { | ||||||
|             return MapThumbnail( |             return MapThumbnail( | ||||||
|               showAttribution: false, |               centre: LatLng( | ||||||
|               coords: LatLng( |  | ||||||
|                 exifInfo?.latitude ?? 0, |                 exifInfo?.latitude ?? 0, | ||||||
|                 exifInfo?.longitude ?? 0, |                 exifInfo?.longitude ?? 0, | ||||||
|               ), |               ), | ||||||
|               height: 150, |               height: 150, | ||||||
|               width: constraints.maxWidth, |               width: constraints.maxWidth, | ||||||
|               zoom: 12.0, |               zoom: 12.0, | ||||||
|               markers: [ |               assetMarkerRemoteId: asset.remoteId, | ||||||
|                 Marker( |  | ||||||
|                   anchorPos: AnchorPos.align(AnchorAlign.top), |  | ||||||
|                   point: LatLng( |  | ||||||
|                     exifInfo?.latitude ?? 0, |  | ||||||
|                     exifInfo?.longitude ?? 0, |  | ||||||
|                   ), |  | ||||||
|                   builder: (ctx) => const Image( |  | ||||||
|                     image: AssetImage('assets/location-pin.png'), |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|               onTap: (tapPosition, latLong) async { |               onTap: (tapPosition, latLong) async { | ||||||
|                 Uri? uri = await createCoordinatesUri(); |                 Uri? uri = await createCoordinatesUri(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ class ImmichAssetGrid extends HookConsumerWidget { | |||||||
|   final bool canDeselect; |   final bool canDeselect; | ||||||
|   final bool? dynamicLayout; |   final bool? dynamicLayout; | ||||||
|   final bool showMultiSelectIndicator; |   final bool showMultiSelectIndicator; | ||||||
|   final void Function(ItemPosition start, ItemPosition end)? |   final void Function(Iterable<ItemPosition> itemPositions)? | ||||||
|       visibleItemsListener; |       visibleItemsListener; | ||||||
|   final Widget? topWidget; |   final Widget? topWidget; | ||||||
|   final bool shrinkWrap; |   final bool shrinkWrap; | ||||||
| @@ -89,8 +89,10 @@ class ImmichAssetGrid extends HookConsumerWidget { | |||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             scale.onUpdate = (details) { |             scale.onUpdate = (details) { | ||||||
|               scaleFactor.value = |               scaleFactor.value = max( | ||||||
|                   max(min(5.0, baseScaleFactor.value * details.scale), 1.0); |                 min(5.0, baseScaleFactor.value * details.scale), | ||||||
|  |                 1.0, | ||||||
|  |               ); | ||||||
|               if (7 - scaleFactor.value.toInt() != perRow.value) { |               if (7 - scaleFactor.value.toInt() != perRow.value) { | ||||||
|                 perRow.value = 7 - scaleFactor.value.toInt(); |                 perRow.value = 7 - scaleFactor.value.toInt(); | ||||||
|               } |               } | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ class ImmichAssetGridView extends StatefulWidget { | |||||||
|   final bool canDeselect; |   final bool canDeselect; | ||||||
|   final bool dynamicLayout; |   final bool dynamicLayout; | ||||||
|   final bool showMultiSelectIndicator; |   final bool showMultiSelectIndicator; | ||||||
|   final void Function(ItemPosition start, ItemPosition end)? |   final void Function(Iterable<ItemPosition> itemPositions)? | ||||||
|       visibleItemsListener; |       visibleItemsListener; | ||||||
|   final Widget? topWidget; |   final Widget? topWidget; | ||||||
|   final int heroOffset; |   final int heroOffset; | ||||||
| @@ -421,15 +421,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | |||||||
|  |  | ||||||
|   void _positionListener() { |   void _positionListener() { | ||||||
|     final values = _itemPositionsListener.itemPositions.value; |     final values = _itemPositionsListener.itemPositions.value; | ||||||
|     final start = values.firstOrNull; |     widget.visibleItemsListener?.call(values); | ||||||
|     final end = values.lastOrNull; |  | ||||||
|     if (start != null && end != null) { |  | ||||||
|       if (start.index <= end.index) { |  | ||||||
|         widget.visibleItemsListener?.call(start, end); |  | ||||||
|       } else { |  | ||||||
|         widget.visibleItemsListener?.call(end, start); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _scrollToTop() { |   void _scrollToTop() { | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								mobile/lib/modules/map/models/map_event.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								mobile/lib/modules/map/models/map_event.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | // ignore_for_file: add-copy-with | ||||||
|  |  | ||||||
|  | sealed class MapEvent { | ||||||
|  |   const MapEvent(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class MapAssetsInBoundsUpdated extends MapEvent { | ||||||
|  |   final List<String> assetRemoteIds; | ||||||
|  |  | ||||||
|  |   const MapAssetsInBoundsUpdated(this.assetRemoteIds); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class MapCloseBottomSheet extends MapEvent {} | ||||||
							
								
								
									
										39
									
								
								mobile/lib/modules/map/models/map_marker.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								mobile/lib/modules/map/models/map_marker.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  | import 'package:openapi/api.dart'; | ||||||
|  |  | ||||||
|  | class MapMarker { | ||||||
|  |   final LatLng latLng; | ||||||
|  |   final String assetRemoteId; | ||||||
|  |   MapMarker({ | ||||||
|  |     required this.latLng, | ||||||
|  |     required this.assetRemoteId, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   MapMarker copyWith({ | ||||||
|  |     LatLng? latLng, | ||||||
|  |     String? assetRemoteId, | ||||||
|  |   }) { | ||||||
|  |     return MapMarker( | ||||||
|  |       latLng: latLng ?? this.latLng, | ||||||
|  |       assetRemoteId: assetRemoteId ?? this.assetRemoteId, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   MapMarker.fromDto(MapMarkerResponseDto dto) | ||||||
|  |       : latLng = LatLng(dto.lat, dto.lon), | ||||||
|  |         assetRemoteId = dto.id; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toString() => | ||||||
|  |       'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(covariant MapMarker other) { | ||||||
|  |     if (identical(this, other)) return true; | ||||||
|  |  | ||||||
|  |     return other.latLng == latLng && other.assetRemoteId == assetRemoteId; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode; | ||||||
|  | } | ||||||
| @@ -1,40 +0,0 @@ | |||||||
| import 'package:immich_mobile/shared/models/asset.dart'; |  | ||||||
|  |  | ||||||
| enum MapPageEventType { |  | ||||||
|   mapTap, |  | ||||||
|   bottomSheetScrolled, |  | ||||||
|   assetsInBoundUpdated, |  | ||||||
|   zoomToAsset, |  | ||||||
|   zoomToCurrentLocation, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class MapPageEventBase { |  | ||||||
|   final MapPageEventType type; |  | ||||||
|  |  | ||||||
|   const MapPageEventBase(this.type); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class MapPageOnTapEvent extends MapPageEventBase { |  | ||||||
|   const MapPageOnTapEvent() : super(MapPageEventType.mapTap); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class MapPageAssetsInBoundUpdated extends MapPageEventBase { |  | ||||||
|   List<Asset> assets; |  | ||||||
|   MapPageAssetsInBoundUpdated(this.assets) |  | ||||||
|       : super(MapPageEventType.assetsInBoundUpdated); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class MapPageBottomSheetScrolled extends MapPageEventBase { |  | ||||||
|   Asset? asset; |  | ||||||
|   MapPageBottomSheetScrolled(this.asset) |  | ||||||
|       : super(MapPageEventType.bottomSheetScrolled); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class MapPageZoomToAsset extends MapPageEventBase { |  | ||||||
|   Asset? asset; |  | ||||||
|   MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class MapPageZoomToLocation extends MapPageEventBase { |  | ||||||
|   const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation); |  | ||||||
| } |  | ||||||
| @@ -1,65 +1,71 @@ | |||||||
| import 'package:vector_map_tiles/vector_map_tiles.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  |  | ||||||
| class MapState { | class MapState { | ||||||
|   final bool isDarkTheme; |   final ThemeMode themeMode; | ||||||
|   final bool showFavoriteOnly; |   final bool showFavoriteOnly; | ||||||
|   final bool includeArchived; |   final bool includeArchived; | ||||||
|   final int relativeTime; |   final int relativeTime; | ||||||
|   final Style? mapStyle; |   final bool shouldRefetchMarkers; | ||||||
|   final bool isLoading; |   final AsyncValue<String> lightStyleFetched; | ||||||
|  |   final AsyncValue<String> darkStyleFetched; | ||||||
|  |  | ||||||
|   MapState({ |   MapState({ | ||||||
|     this.isDarkTheme = false, |     this.themeMode = ThemeMode.system, | ||||||
|     this.showFavoriteOnly = false, |     this.showFavoriteOnly = false, | ||||||
|     this.includeArchived = false, |     this.includeArchived = false, | ||||||
|     this.relativeTime = 0, |     this.relativeTime = 0, | ||||||
|     this.mapStyle, |     this.shouldRefetchMarkers = false, | ||||||
|     this.isLoading = false, |     this.lightStyleFetched = const AsyncLoading(), | ||||||
|  |     this.darkStyleFetched = const AsyncLoading(), | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   MapState copyWith({ |   MapState copyWith({ | ||||||
|     bool? isDarkTheme, |     ThemeMode? themeMode, | ||||||
|     bool? showFavoriteOnly, |     bool? showFavoriteOnly, | ||||||
|     bool? includeArchived, |     bool? includeArchived, | ||||||
|     int? relativeTime, |     int? relativeTime, | ||||||
|     Style? mapStyle, |     bool? shouldRefetchMarkers, | ||||||
|     bool? isLoading, |     AsyncValue<String>? lightStyleFetched, | ||||||
|  |     AsyncValue<String>? darkStyleFetched, | ||||||
|   }) { |   }) { | ||||||
|     return MapState( |     return MapState( | ||||||
|       isDarkTheme: isDarkTheme ?? this.isDarkTheme, |       themeMode: themeMode ?? this.themeMode, | ||||||
|       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, |       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, | ||||||
|       includeArchived: includeArchived ?? this.includeArchived, |       includeArchived: includeArchived ?? this.includeArchived, | ||||||
|       relativeTime: relativeTime ?? this.relativeTime, |       relativeTime: relativeTime ?? this.relativeTime, | ||||||
|       mapStyle: mapStyle ?? this.mapStyle, |       shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers, | ||||||
|       isLoading: isLoading ?? this.isLoading, |       lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched, | ||||||
|  |       darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String toString() { |   String toString() { | ||||||
|     return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)'; |     return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   bool operator ==(Object other) { |   bool operator ==(covariant MapState other) { | ||||||
|     if (identical(this, other)) return true; |     if (identical(this, other)) return true; | ||||||
|  |  | ||||||
|     return other is MapState && |     return other.themeMode == themeMode && | ||||||
|         other.isDarkTheme == isDarkTheme && |  | ||||||
|         other.showFavoriteOnly == showFavoriteOnly && |         other.showFavoriteOnly == showFavoriteOnly && | ||||||
|         other.relativeTime == relativeTime && |  | ||||||
|         other.includeArchived == includeArchived && |         other.includeArchived == includeArchived && | ||||||
|         other.mapStyle == mapStyle && |         other.relativeTime == relativeTime && | ||||||
|         other.isLoading == isLoading; |         other.shouldRefetchMarkers == shouldRefetchMarkers && | ||||||
|  |         other.lightStyleFetched == lightStyleFetched && | ||||||
|  |         other.darkStyleFetched == darkStyleFetched; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   int get hashCode { |   int get hashCode { | ||||||
|     return isDarkTheme.hashCode ^ |     return themeMode.hashCode ^ | ||||||
|         showFavoriteOnly.hashCode ^ |         showFavoriteOnly.hashCode ^ | ||||||
|         relativeTime.hashCode ^ |  | ||||||
|         includeArchived.hashCode ^ |         includeArchived.hashCode ^ | ||||||
|         mapStyle.hashCode ^ |         relativeTime.hashCode ^ | ||||||
|         isLoading.hashCode; |         shouldRefetchMarkers.hashCode ^ | ||||||
|  |         lightStyleFetched.hashCode ^ | ||||||
|  |         darkStyleFetched.hashCode; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,13 +1,14 @@ | |||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:immich_mobile/modules/map/models/map_marker.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/providers/map_service.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/map/services/map.service.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:immich_mobile/shared/models/asset.dart'; |  | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
|  |  | ||||||
| final mapMarkersProvider = | part 'map_marker.provider.g.dart'; | ||||||
|     FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async { |  | ||||||
|  | @riverpod | ||||||
|  | Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async { | ||||||
|   final service = ref.read(mapServiceProvider); |   final service = ref.read(mapServiceProvider); | ||||||
|   final mapState = ref.read(mapStateNotifier); |   final mapState = ref.read(mapStateNotifierProvider); | ||||||
|   DateTime? fileCreatedAfter; |   DateTime? fileCreatedAfter; | ||||||
|   bool? isFavorite; |   bool? isFavorite; | ||||||
|   bool? isIncludeArchived; |   bool? isIncludeArchived; | ||||||
| @@ -31,34 +32,5 @@ final mapMarkersProvider = | |||||||
|     fileCreatedAfter: fileCreatedAfter, |     fileCreatedAfter: fileCreatedAfter, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   final assetMarkerData = await Future.wait( |   return markers.toList(); | ||||||
|     markers.map((e) async { |  | ||||||
|       final asset = await service.getAssetForMarkerId(e.id); |  | ||||||
|       bool hasInvalidCoords = e.lat < -90 || e.lat > 90; |  | ||||||
|       hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180); |  | ||||||
|       if (asset == null || hasInvalidCoords) return null; |  | ||||||
|       return AssetMarkerData(asset, LatLng(e.lat, e.lon)); |  | ||||||
|     }), |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   return assetMarkerData.nonNulls.toSet(); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| class AssetMarkerData { |  | ||||||
|   final LatLng point; |  | ||||||
|   final Asset asset; |  | ||||||
|  |  | ||||||
|   const AssetMarkerData(this.asset, this.point); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   bool operator ==(Object other) { |  | ||||||
|     if (identical(this, other)) return true; |  | ||||||
|  |  | ||||||
|     return other is AssetMarkerData && other.asset.remoteId == asset.remoteId; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   int get hashCode { |  | ||||||
|     return asset.remoteId.hashCode; |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								mobile/lib/modules/map/providers/map_marker.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								mobile/lib/modules/map/providers/map_marker.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  | 
 | ||||||
|  | part of 'map_marker.provider.dart'; | ||||||
|  | 
 | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  | 
 | ||||||
|  | String _$mapMarkersHash() => r'90b00b7f85c54b19f56c7d55d3ad8575c09dab3c'; | ||||||
|  | 
 | ||||||
|  | /// See also [mapMarkers]. | ||||||
|  | @ProviderFor(mapMarkers) | ||||||
|  | final mapMarkersProvider = AutoDisposeFutureProvider<List<MapMarker>>.internal( | ||||||
|  |   mapMarkers, | ||||||
|  |   name: r'mapMarkersProvider', | ||||||
|  |   debugGetCreateSourceHash: | ||||||
|  |       const bool.fromEnvironment('dart.vm.product') ? null : _$mapMarkersHash, | ||||||
|  |   dependencies: null, | ||||||
|  |   allTransitiveDependencies: null, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | typedef MapMarkersRef = AutoDisposeFutureProviderRef<List<MapMarker>>; | ||||||
|  | // ignore_for_file: type=lint | ||||||
|  | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | import 'package:immich_mobile/modules/map/services/map.service.dart'; | ||||||
|  | import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||||
|  | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  |  | ||||||
|  | part 'map_service.provider.g.dart'; | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | MapSerivce mapService(MapServiceRef ref) => | ||||||
|  |     MapSerivce(ref.watch(apiServiceProvider)); | ||||||
							
								
								
									
										24
									
								
								mobile/lib/modules/map/providers/map_service.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								mobile/lib/modules/map/providers/map_service.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  | 
 | ||||||
|  | part of 'map_service.provider.dart'; | ||||||
|  | 
 | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  | 
 | ||||||
|  | String _$mapServiceHash() => r'2f68c07ac6cd5c74ec8be3bd2df91f4db673b79e'; | ||||||
|  | 
 | ||||||
|  | /// See also [mapService]. | ||||||
|  | @ProviderFor(mapService) | ||||||
|  | final mapServiceProvider = AutoDisposeProvider<MapSerivce>.internal( | ||||||
|  |   mapService, | ||||||
|  |   name: r'mapServiceProvider', | ||||||
|  |   debugGetCreateSourceHash: | ||||||
|  |       const bool.fromEnvironment('dart.vm.product') ? null : _$mapServiceHash, | ||||||
|  |   dependencies: null, | ||||||
|  |   allTransitiveDependencies: null, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | typedef MapServiceRef = AutoDisposeProviderRef<MapSerivce>; | ||||||
|  | // ignore_for_file: type=lint | ||||||
|  | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member | ||||||
| @@ -1,159 +1,138 @@ | |||||||
| import 'dart:convert'; |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
| import 'package:flutter/foundation.dart'; |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_map/flutter_map.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/models/map_state.model.dart'; | import 'package:immich_mobile/modules/map/models/map_state.model.dart'; | ||||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||||
| import 'package:immich_mobile/shared/services/api.service.dart'; |  | ||||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; |  | ||||||
| import 'package:immich_mobile/utils/color_filter_generator.dart'; |  | ||||||
| import 'package:logging/logging.dart'; | import 'package:logging/logging.dart'; | ||||||
| import 'package:openapi/api.dart'; | import 'package:openapi/api.dart'; | ||||||
| import 'package:vector_map_tiles/vector_map_tiles.dart'; | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  |  | ||||||
| class MapStateNotifier extends StateNotifier<MapState> { | part 'map_state.provider.g.dart'; | ||||||
|   MapStateNotifier(this._appSettingsProvider, this._apiService) |  | ||||||
|       : super( | @Riverpod(keepAlive: true) | ||||||
|           MapState( | class MapStateNotifier extends _$MapStateNotifier { | ||||||
|             isDarkTheme: _appSettingsProvider |   final _log = Logger("MapStateNotifier"); | ||||||
|                 .getSetting<bool>(AppSettingsEnum.mapThemeMode), |  | ||||||
|             showFavoriteOnly: _appSettingsProvider |   @override | ||||||
|                 .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly), |   MapState build() { | ||||||
|             includeArchived: _appSettingsProvider |     final appSettingsProvider = ref.read(appSettingsServiceProvider); | ||||||
|                 .getSetting<bool>(AppSettingsEnum.mapIncludeArchived), |  | ||||||
|             relativeTime: _appSettingsProvider |     // Fetch and save the Style JSONs | ||||||
|                 .getSetting<int>(AppSettingsEnum.mapRelativeDate), |     loadStyles(); | ||||||
|             isLoading: true, |     return MapState( | ||||||
|           ), |       themeMode: ThemeMode.values[ | ||||||
|         ) { |           appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)], | ||||||
|     _fetchStyleFromServer( |       showFavoriteOnly: appSettingsProvider | ||||||
|       _appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode), |           .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly), | ||||||
|  |       includeArchived: appSettingsProvider | ||||||
|  |           .getSetting<bool>(AppSettingsEnum.mapIncludeArchived), | ||||||
|  |       relativeTime: | ||||||
|  |           appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   final AppSettingsService _appSettingsProvider; |   void loadStyles() async { | ||||||
|   final ApiService _apiService; |     final documents = (await getApplicationDocumentsDirectory()).path; | ||||||
|   final Logger _log = Logger("MapStateNotifier"); |  | ||||||
|  |  | ||||||
|   bool get isRaster => |     // Set to loading | ||||||
|       state.mapStyle != null && state.mapStyle!.rasterTileProvider != null; |     state = state.copyWith(lightStyleFetched: const AsyncLoading()); | ||||||
|  |  | ||||||
|   double get maxZoom => |     // Fetch and save light theme | ||||||
|       (isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 18) |     final lightResponse = await ref | ||||||
|           .toDouble(); |         .read(apiServiceProvider) | ||||||
|  |         .systemConfigApi | ||||||
|  |         .getMapStyleWithHttpInfo(MapTheme.light); | ||||||
|  |  | ||||||
|   void switchTheme(bool isDarkTheme) { |     if (lightResponse.statusCode >= HttpStatus.badRequest) { | ||||||
|     _updateThemeMode(isDarkTheme); |       state = state.copyWith( | ||||||
|     _fetchStyleFromServer(isDarkTheme); |         lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), | ||||||
|   } |       ); | ||||||
|  |       _log.severe( | ||||||
|   void _updateThemeMode(bool isDarkTheme) { |         "Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}", | ||||||
|     _appSettingsProvider.setSetting( |       ); | ||||||
|       AppSettingsEnum.mapThemeMode, |  | ||||||
|       isDarkTheme, |  | ||||||
|     ); |  | ||||||
|     state = state.copyWith(isDarkTheme: isDarkTheme, isLoading: true); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _fetchStyleFromServer(bool isDarkTheme) async { |  | ||||||
|     final styleResponse = await _apiService.systemConfigApi |  | ||||||
|         .getMapStyleWithHttpInfo(isDarkTheme ? MapTheme.dark : MapTheme.light); |  | ||||||
|     if (styleResponse.statusCode >= HttpStatus.badRequest) { |  | ||||||
|       throw ApiException(styleResponse.statusCode, styleResponse.body); |  | ||||||
|     } |  | ||||||
|     final styleJsonString = styleResponse.body.isNotEmpty && |  | ||||||
|             styleResponse.statusCode != HttpStatus.noContent |  | ||||||
|         ? styleResponse.body |  | ||||||
|         : null; |  | ||||||
|  |  | ||||||
|     if (styleJsonString == null) { |  | ||||||
|       _log.severe('Style JSON from server is empty'); |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     final styleJson = await compute(jsonDecode, styleJsonString); |  | ||||||
|     if (styleJson is! Map<String, dynamic>) { |     final lightJSON = lightResponse.body; | ||||||
|       _log.severe('Style JSON from server is invalid'); |     final lightFile = await File("$documents/map-style-light.json") | ||||||
|  |         .writeAsString(lightJSON, flush: true); | ||||||
|  |  | ||||||
|  |     // Update state with path | ||||||
|  |     state = | ||||||
|  |         state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path)); | ||||||
|  |  | ||||||
|  |     // Set to loading | ||||||
|  |     state = state.copyWith(darkStyleFetched: const AsyncLoading()); | ||||||
|  |  | ||||||
|  |     // Fetch and save dark theme | ||||||
|  |     final darkResponse = await ref | ||||||
|  |         .read(apiServiceProvider) | ||||||
|  |         .systemConfigApi | ||||||
|  |         .getMapStyleWithHttpInfo(MapTheme.dark); | ||||||
|  |  | ||||||
|  |     if (darkResponse.statusCode >= HttpStatus.badRequest) { | ||||||
|  |       state = state.copyWith( | ||||||
|  |         darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), | ||||||
|  |       ); | ||||||
|  |       _log.severe( | ||||||
|  |         "Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}", | ||||||
|  |       ); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     final styleReader = StyleReader(uri: ''); |  | ||||||
|     Style? style; |     final darkJSON = darkResponse.body; | ||||||
|     try { |     final darkFile = await File("$documents/map-style-dark.json") | ||||||
|       style = await styleReader.readFromMap(styleJson); |         .writeAsString(darkJSON, flush: true); | ||||||
|     } finally { |  | ||||||
|       // Consume all error |     // Update state with path | ||||||
|     } |     state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); | ||||||
|     state = state.copyWith( |   } | ||||||
|       mapStyle: style, |  | ||||||
|       isLoading: false, |   void switchTheme(ThemeMode mode) { | ||||||
|     ); |     ref.read(appSettingsServiceProvider).setSetting( | ||||||
|  |           AppSettingsEnum.mapThemeMode, | ||||||
|  |           mode.index, | ||||||
|  |         ); | ||||||
|  |     state = state.copyWith(themeMode: mode); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void switchFavoriteOnly(bool isFavoriteOnly) { |   void switchFavoriteOnly(bool isFavoriteOnly) { | ||||||
|     _appSettingsProvider.setSetting( |     ref.read(appSettingsServiceProvider).setSetting( | ||||||
|       AppSettingsEnum.mapShowFavoriteOnly, |           AppSettingsEnum.mapShowFavoriteOnly, | ||||||
|       isFavoriteOnly, |           isFavoriteOnly, | ||||||
|  |         ); | ||||||
|  |     state = state.copyWith( | ||||||
|  |       showFavoriteOnly: isFavoriteOnly, | ||||||
|  |       shouldRefetchMarkers: true, | ||||||
|     ); |     ); | ||||||
|     state = state.copyWith(showFavoriteOnly: isFavoriteOnly); |   } | ||||||
|  |  | ||||||
|  |   void setRefetchMarkers(bool shouldRefetch) { | ||||||
|  |     state = state.copyWith(shouldRefetchMarkers: shouldRefetch); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void switchIncludeArchived(bool isIncludeArchived) { |   void switchIncludeArchived(bool isIncludeArchived) { | ||||||
|     _appSettingsProvider.setSetting( |     ref.read(appSettingsServiceProvider).setSetting( | ||||||
|       AppSettingsEnum.mapIncludeArchived, |           AppSettingsEnum.mapIncludeArchived, | ||||||
|       isIncludeArchived, |           isIncludeArchived, | ||||||
|  |         ); | ||||||
|  |     state = state.copyWith( | ||||||
|  |       includeArchived: isIncludeArchived, | ||||||
|  |       shouldRefetchMarkers: true, | ||||||
|     ); |     ); | ||||||
|     state = state.copyWith(includeArchived: isIncludeArchived); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void setRelativeTime(int relativeTime) { |   void setRelativeTime(int relativeTime) { | ||||||
|     _appSettingsProvider.setSetting( |     ref.read(appSettingsServiceProvider).setSetting( | ||||||
|       AppSettingsEnum.mapRelativeDate, |           AppSettingsEnum.mapRelativeDate, | ||||||
|       relativeTime, |           relativeTime, | ||||||
|  |         ); | ||||||
|  |     state = state.copyWith( | ||||||
|  |       relativeTime: relativeTime, | ||||||
|  |       shouldRefetchMarkers: true, | ||||||
|     ); |     ); | ||||||
|     state = state.copyWith(relativeTime: relativeTime); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Widget getTileLayer([bool forceDark = false]) { |  | ||||||
|     if (isRaster) { |  | ||||||
|       final rasterProvider = state.mapStyle!.rasterTileProvider; |  | ||||||
|       final rasterLayer = TileLayer( |  | ||||||
|         urlTemplate: rasterProvider!.url, |  | ||||||
|         maxNativeZoom: rasterProvider.maximumZoom, |  | ||||||
|         maxZoom: rasterProvider.maximumZoom.toDouble(), |  | ||||||
|       ); |  | ||||||
|       return state.isDarkTheme || forceDark |  | ||||||
|           ? InvertionFilter( |  | ||||||
|               child: SaturationFilter( |  | ||||||
|                 saturation: -1, |  | ||||||
|                 child: BrightnessFilter( |  | ||||||
|                   brightness: -1, |  | ||||||
|                   child: rasterLayer, |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ) |  | ||||||
|           : rasterLayer; |  | ||||||
|     } |  | ||||||
|     if (state.mapStyle != null && !isRaster) { |  | ||||||
|       return VectorTileLayer( |  | ||||||
|         // Tiles and themes will be set for vector providers |  | ||||||
|         tileProviders: state.mapStyle!.providers!, |  | ||||||
|         theme: state.mapStyle!.theme!, |  | ||||||
|         sprites: state.mapStyle!.sprites, |  | ||||||
|         concurrency: 6, |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|     return const Center(child: ImmichLoadingIndicator()); |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| final mapStateNotifier = |  | ||||||
|     StateNotifierProvider<MapStateNotifier, MapState>((ref) { |  | ||||||
|   return MapStateNotifier( |  | ||||||
|     ref.watch(appSettingsServiceProvider), |  | ||||||
|     ref.watch(apiServiceProvider), |  | ||||||
|   ); |  | ||||||
| }); |  | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								mobile/lib/modules/map/providers/map_state.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								mobile/lib/modules/map/providers/map_state.provider.g.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  | 
 | ||||||
|  | part of 'map_state.provider.dart'; | ||||||
|  | 
 | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  | 
 | ||||||
|  | String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52'; | ||||||
|  | 
 | ||||||
|  | /// See also [MapStateNotifier]. | ||||||
|  | @ProviderFor(MapStateNotifier) | ||||||
|  | final mapStateNotifierProvider = | ||||||
|  |     NotifierProvider<MapStateNotifier, MapState>.internal( | ||||||
|  |   MapStateNotifier.new, | ||||||
|  |   name: r'mapStateNotifierProvider', | ||||||
|  |   debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') | ||||||
|  |       ? null | ||||||
|  |       : _$mapStateNotifierHash, | ||||||
|  |   dependencies: null, | ||||||
|  |   allTransitiveDependencies: null, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | typedef _$MapStateNotifier = Notifier<MapState>; | ||||||
|  | // ignore_for_file: type=lint | ||||||
|  | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member | ||||||
| @@ -1,62 +1,33 @@ | |||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:immich_mobile/mixins/error_logger.mixin.dart'; | ||||||
| import 'package:immich_mobile/shared/models/asset.dart'; | import 'package:immich_mobile/modules/map/models/map_marker.dart'; | ||||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; |  | ||||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; |  | ||||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | import 'package:immich_mobile/shared/services/api.service.dart'; | ||||||
| import 'package:isar/isar.dart'; |  | ||||||
| import 'package:logging/logging.dart'; | import 'package:logging/logging.dart'; | ||||||
| import 'package:openapi/api.dart'; |  | ||||||
|  |  | ||||||
| final mapServiceProvider = Provider( | class MapSerivce with ErrorLoggerMixin { | ||||||
|   (ref) => MapSerivce( |  | ||||||
|     ref.read(apiServiceProvider), |  | ||||||
|     ref.read(dbProvider), |  | ||||||
|   ), |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| class MapSerivce { |  | ||||||
|   final ApiService _apiService; |   final ApiService _apiService; | ||||||
|   final Isar _db; |   @override | ||||||
|   final _log = Logger("MapService"); |   final logger = Logger("MapService"); | ||||||
|  |  | ||||||
|   MapSerivce(this._apiService, this._db); |   MapSerivce(this._apiService); | ||||||
|  |  | ||||||
|   Future<List<MapMarkerResponseDto>> getMapMarkers({ |   Future<Iterable<MapMarker>> getMapMarkers({ | ||||||
|     bool? isFavorite, |     bool? isFavorite, | ||||||
|     bool? withArchived, |     bool? withArchived, | ||||||
|     DateTime? fileCreatedAfter, |     DateTime? fileCreatedAfter, | ||||||
|     DateTime? fileCreatedBefore, |     DateTime? fileCreatedBefore, | ||||||
|   }) async { |   }) async { | ||||||
|     try { |     return logError( | ||||||
|       final markers = await _apiService.assetApi.getMapMarkers( |       () async { | ||||||
|         isFavorite: isFavorite, |         final markers = await _apiService.assetApi.getMapMarkers( | ||||||
|         isArchived: withArchived, |           isFavorite: isFavorite, | ||||||
|         fileCreatedAfter: fileCreatedAfter, |           isArchived: withArchived, | ||||||
|         fileCreatedBefore: fileCreatedBefore, |           fileCreatedAfter: fileCreatedAfter, | ||||||
|       ); |           fileCreatedBefore: fileCreatedBefore, | ||||||
|  |         ); | ||||||
|  |  | ||||||
|       return markers ?? []; |         return markers?.map(MapMarker.fromDto) ?? []; | ||||||
|     } catch (error, stack) { |       }, | ||||||
|       _log.severe("Cannot get map markers ${error.toString()}", error, stack); |       defaultValue: [], | ||||||
|       return []; |     ); | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<Asset?> getAssetForMarkerId(String remoteId) async { |  | ||||||
|     try { |  | ||||||
|       final assets = await _db.assets.getAllByRemoteId([remoteId]); |  | ||||||
|       if (assets.isNotEmpty) return assets[0]; |  | ||||||
|  |  | ||||||
|       final dto = await _apiService.assetApi.getAssetById(remoteId); |  | ||||||
|       if (dto == null) return null; |  | ||||||
|       return _db.assets.getByRemoteId(dto.id); |  | ||||||
|     } catch (error, stack) { |  | ||||||
|       _log.severe( |  | ||||||
|         "Cannot get asset for marker ${error.toString()}", |  | ||||||
|         error, |  | ||||||
|         stack, |  | ||||||
|       ); |  | ||||||
|       return null; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,30 +0,0 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:geolocator/geolocator.dart'; |  | ||||||
| import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; |  | ||||||
|  |  | ||||||
| class LocationServiceDisabledDialog extends ConfirmDialog { |  | ||||||
|   LocationServiceDisabledDialog({Key? key}) |  | ||||||
|       : super( |  | ||||||
|           key: key, |  | ||||||
|           title: 'map_location_service_disabled_title'.tr(), |  | ||||||
|           content: 'map_location_service_disabled_content'.tr(), |  | ||||||
|           cancel: 'map_location_dialog_cancel'.tr(), |  | ||||||
|           ok: 'map_location_dialog_yes'.tr(), |  | ||||||
|           onOk: () async { |  | ||||||
|             await Geolocator.openLocationSettings(); |  | ||||||
|           }, |  | ||||||
|         ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class LocationPermissionDisabledDialog extends ConfirmDialog { |  | ||||||
|   LocationPermissionDisabledDialog({Key? key}) |  | ||||||
|       : super( |  | ||||||
|           key: key, |  | ||||||
|           title: 'map_no_location_permission_title'.tr(), |  | ||||||
|           content: 'map_no_location_permission_content'.tr(), |  | ||||||
|           cancel: 'map_location_dialog_cancel'.tr(), |  | ||||||
|           ok: 'map_location_dialog_yes'.tr(), |  | ||||||
|           onOk: () {}, |  | ||||||
|         ); |  | ||||||
| } |  | ||||||
| @@ -1,114 +0,0 @@ | |||||||
| import 'package:auto_route/auto_route.dart'; |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
| import 'package:flutter_map/flutter_map.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; |  | ||||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; |  | ||||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; |  | ||||||
| import 'package:immich_mobile/utils/immich_app_theme.dart'; |  | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
|  |  | ||||||
| class MapLocationPickerPage extends HookConsumerWidget { |  | ||||||
|   final LatLng? initialLatLng; |  | ||||||
|  |  | ||||||
|   const MapLocationPickerPage({super.key, this.initialLatLng}); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |  | ||||||
|     final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0)); |  | ||||||
|     final isDarkTheme = |  | ||||||
|         ref.watch(mapStateNotifier.select((state) => state.isDarkTheme)); |  | ||||||
|     final isLoading = |  | ||||||
|         ref.watch(mapStateNotifier.select((state) => state.isLoading)); |  | ||||||
|     final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom; |  | ||||||
|  |  | ||||||
|     return Theme( |  | ||||||
|       // Override app theme based on map theme |  | ||||||
|       data: isDarkTheme ? immichDarkTheme : immichLightTheme, |  | ||||||
|       child: Scaffold( |  | ||||||
|         extendBodyBehindAppBar: true, |  | ||||||
|         body: Stack( |  | ||||||
|           children: [ |  | ||||||
|             if (!isLoading) |  | ||||||
|               FlutterMap( |  | ||||||
|                 options: MapOptions( |  | ||||||
|                   maxBounds: |  | ||||||
|                       LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)), |  | ||||||
|                   interactiveFlags: InteractiveFlag.doubleTapZoom | |  | ||||||
|                       InteractiveFlag.drag | |  | ||||||
|                       InteractiveFlag.flingAnimation | |  | ||||||
|                       InteractiveFlag.pinchMove | |  | ||||||
|                       InteractiveFlag.pinchZoom, |  | ||||||
|                   center: LatLng(20, 20), |  | ||||||
|                   zoom: 2, |  | ||||||
|                   minZoom: 1, |  | ||||||
|                   maxZoom: maxZoom, |  | ||||||
|                   onTap: (tapPosition, point) => selectedLatLng.value = point, |  | ||||||
|                 ), |  | ||||||
|                 children: [ |  | ||||||
|                   ref.read(mapStateNotifier.notifier).getTileLayer(), |  | ||||||
|                   MarkerLayer( |  | ||||||
|                     markers: [ |  | ||||||
|                       Marker( |  | ||||||
|                         anchorPos: AnchorPos.align(AnchorAlign.top), |  | ||||||
|                         point: selectedLatLng.value, |  | ||||||
|                         builder: (ctx) => const Image( |  | ||||||
|                           image: AssetImage('assets/location-pin.png'), |  | ||||||
|                         ), |  | ||||||
|                         height: 40, |  | ||||||
|                         width: 40, |  | ||||||
|                       ), |  | ||||||
|                     ], |  | ||||||
|                   ), |  | ||||||
|                 ], |  | ||||||
|               ), |  | ||||||
|             if (isLoading) |  | ||||||
|               Positioned( |  | ||||||
|                 top: context.height * 0.35, |  | ||||||
|                 left: context.width * 0.425, |  | ||||||
|                 child: const ImmichLoadingIndicator(), |  | ||||||
|               ), |  | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|         bottomSheet: BottomSheet( |  | ||||||
|           onClosing: () {}, |  | ||||||
|           builder: (context) => SizedBox( |  | ||||||
|             height: 150, |  | ||||||
|             child: Column( |  | ||||||
|               mainAxisAlignment: MainAxisAlignment.spaceEvenly, |  | ||||||
|               crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|               children: [ |  | ||||||
|                 Text( |  | ||||||
|                   "${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}", |  | ||||||
|                   style: context.textTheme.bodyLarge?.copyWith( |  | ||||||
|                     color: context.primaryColor, |  | ||||||
|                     fontWeight: FontWeight.w600, |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|                 Row( |  | ||||||
|                   mainAxisAlignment: MainAxisAlignment.spaceEvenly, |  | ||||||
|                   children: [ |  | ||||||
|                     ElevatedButton( |  | ||||||
|                       onPressed: () => context.popRoute(selectedLatLng.value), |  | ||||||
|                       child: const Text("map_location_picker_page_use_location") |  | ||||||
|                           .tr(), |  | ||||||
|                     ), |  | ||||||
|                     ElevatedButton( |  | ||||||
|                       onPressed: () => context.popRoute(), |  | ||||||
|                       style: ElevatedButton.styleFrom( |  | ||||||
|                         backgroundColor: context.colorScheme.error, |  | ||||||
|                       ), |  | ||||||
|                       child: const Text("action_common_cancel").tr(), |  | ||||||
|                     ), |  | ||||||
|                   ], |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,138 +0,0 @@ | |||||||
| import 'dart:io'; |  | ||||||
|  |  | ||||||
| import 'package:auto_route/auto_route.dart'; |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart'; |  | ||||||
|  |  | ||||||
| class MapAppBar extends HookWidget implements PreferredSizeWidget { |  | ||||||
|   final ValueNotifier<bool> selectionEnabled; |  | ||||||
|   final int selectedAssetsLength; |  | ||||||
|   final bool isDarkTheme; |  | ||||||
|  |  | ||||||
|   final void Function() onShare; |  | ||||||
|   final void Function() onFavorite; |  | ||||||
|   final void Function() onArchive; |  | ||||||
|  |  | ||||||
|   const MapAppBar({ |  | ||||||
|     super.key, |  | ||||||
|     required this.selectionEnabled, |  | ||||||
|     required this.selectedAssetsLength, |  | ||||||
|     required this.onShare, |  | ||||||
|     required this.onArchive, |  | ||||||
|     required this.onFavorite, |  | ||||||
|     this.isDarkTheme = false, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   List<Widget> buildNonSelectionWidgets(BuildContext context) { |  | ||||||
|     return [ |  | ||||||
|       Padding( |  | ||||||
|         padding: const EdgeInsets.only(left: 15, top: 15), |  | ||||||
|         child: ElevatedButton( |  | ||||||
|           onPressed: () => context.popRoute(), |  | ||||||
|           style: ElevatedButton.styleFrom( |  | ||||||
|             shape: const CircleBorder(), |  | ||||||
|             padding: const EdgeInsets.all(12), |  | ||||||
|           ), |  | ||||||
|           child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|       Padding( |  | ||||||
|         padding: const EdgeInsets.only(right: 15, top: 15), |  | ||||||
|         child: ElevatedButton( |  | ||||||
|           onPressed: () => showDialog( |  | ||||||
|             context: context, |  | ||||||
|             builder: (BuildContext _) { |  | ||||||
|               return const MapSettingsDialog(); |  | ||||||
|             }, |  | ||||||
|           ), |  | ||||||
|           style: ElevatedButton.styleFrom( |  | ||||||
|             shape: const CircleBorder(), |  | ||||||
|             padding: const EdgeInsets.all(12), |  | ||||||
|           ), |  | ||||||
|           child: const Icon(Icons.more_vert_rounded, size: 22), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   List<Widget> buildSelectionWidgets() { |  | ||||||
|     return [ |  | ||||||
|       DisableMultiSelectButton( |  | ||||||
|         onPressed: () { |  | ||||||
|           selectionEnabled.value = false; |  | ||||||
|         }, |  | ||||||
|         selectedItemCount: selectedAssetsLength, |  | ||||||
|       ), |  | ||||||
|       Row( |  | ||||||
|         children: [ |  | ||||||
|           // Share button |  | ||||||
|           Padding( |  | ||||||
|             padding: const EdgeInsets.only(top: 15), |  | ||||||
|             child: ElevatedButton( |  | ||||||
|               onPressed: onShare, |  | ||||||
|               style: ElevatedButton.styleFrom( |  | ||||||
|                 shape: const CircleBorder(), |  | ||||||
|                 padding: const EdgeInsets.all(12), |  | ||||||
|               ), |  | ||||||
|               child: Icon( |  | ||||||
|                 Platform.isAndroid |  | ||||||
|                     ? Icons.share_rounded |  | ||||||
|                     : Icons.ios_share_rounded, |  | ||||||
|                 size: 22, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           // Favorite button |  | ||||||
|           Padding( |  | ||||||
|             padding: const EdgeInsets.only(top: 15), |  | ||||||
|             child: ElevatedButton( |  | ||||||
|               onPressed: onFavorite, |  | ||||||
|               style: ElevatedButton.styleFrom( |  | ||||||
|                 shape: const CircleBorder(), |  | ||||||
|                 padding: const EdgeInsets.all(12), |  | ||||||
|               ), |  | ||||||
|               child: const Icon( |  | ||||||
|                 Icons.favorite, |  | ||||||
|                 size: 22, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           // Archive Button |  | ||||||
|           Padding( |  | ||||||
|             padding: const EdgeInsets.only(right: 10, top: 15), |  | ||||||
|             child: ElevatedButton( |  | ||||||
|               onPressed: onArchive, |  | ||||||
|               style: ElevatedButton.styleFrom( |  | ||||||
|                 shape: const CircleBorder(), |  | ||||||
|                 padding: const EdgeInsets.all(12), |  | ||||||
|               ), |  | ||||||
|               child: const Icon( |  | ||||||
|                 Icons.archive, |  | ||||||
|                 size: 22, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ], |  | ||||||
|       ), |  | ||||||
|     ]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return Padding( |  | ||||||
|       padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15), |  | ||||||
|       child: Row( |  | ||||||
|         mainAxisAlignment: MainAxisAlignment.spaceBetween, |  | ||||||
|         children: [ |  | ||||||
|           if (!selectionEnabled.value) ...buildNonSelectionWidgets(context), |  | ||||||
|           if (selectionEnabled.value) ...buildSelectionWidgets(), |  | ||||||
|         ], |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Size get preferredSize => const Size.fromHeight(100); |  | ||||||
| } |  | ||||||
| @@ -1,356 +0,0 @@ | |||||||
| import 'dart:async'; |  | ||||||
| import 'dart:io'; |  | ||||||
|  |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; |  | ||||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; |  | ||||||
| import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart'; |  | ||||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; |  | ||||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; |  | ||||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/models/map_page_event.model.dart'; |  | ||||||
| import 'package:immich_mobile/shared/models/asset.dart'; |  | ||||||
| import 'package:immich_mobile/shared/ui/drag_sheet.dart'; |  | ||||||
| import 'package:immich_mobile/utils/color_filter_generator.dart'; |  | ||||||
| import 'package:immich_mobile/utils/debounce.dart'; |  | ||||||
| import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; |  | ||||||
|  |  | ||||||
| class MapPageBottomSheet extends StatefulHookConsumerWidget { |  | ||||||
|   final Stream mapPageEventStream; |  | ||||||
|   final StreamController bottomSheetEventSC; |  | ||||||
|   final bool selectionEnabled; |  | ||||||
|   final ImmichAssetGridSelectionListener selectionlistener; |  | ||||||
|   final bool isDarkTheme; |  | ||||||
|  |  | ||||||
|   const MapPageBottomSheet({ |  | ||||||
|     super.key, |  | ||||||
|     required this.mapPageEventStream, |  | ||||||
|     required this.bottomSheetEventSC, |  | ||||||
|     required this.selectionEnabled, |  | ||||||
|     required this.selectionlistener, |  | ||||||
|     this.isDarkTheme = false, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   AssetsInBoundBottomSheetState createState() => |  | ||||||
|       AssetsInBoundBottomSheetState(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> { |  | ||||||
|   // Non-State variables |  | ||||||
|   bool userTappedOnMap = false; |  | ||||||
|   RenderList? _cachedRenderList; |  | ||||||
|   int assetOffsetInSheet = -1; |  | ||||||
|   late final DraggableScrollableController bottomSheetController; |  | ||||||
|   late final Debounce debounce; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void initState() { |  | ||||||
|     super.initState(); |  | ||||||
|     bottomSheetController = DraggableScrollableController(); |  | ||||||
|     debounce = Debounce( |  | ||||||
|       const Duration(milliseconds: 100), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     final isDarkTheme = context.isDarkTheme; |  | ||||||
|     final bottomPadding = |  | ||||||
|         Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0; |  | ||||||
|     final maxHeight = context.height - bottomPadding; |  | ||||||
|     final isSheetScrolled = useState(false); |  | ||||||
|     final isSheetExpanded = useState(false); |  | ||||||
|     final assetsInBound = useState(<Asset>[]); |  | ||||||
|     final currentExtend = useState(0.1); |  | ||||||
|  |  | ||||||
|     void handleMapPageEvents(dynamic event) { |  | ||||||
|       if (event is MapPageAssetsInBoundUpdated) { |  | ||||||
|         assetsInBound.value = event.assets; |  | ||||||
|       } else if (event is MapPageOnTapEvent) { |  | ||||||
|         userTappedOnMap = true; |  | ||||||
|         assetOffsetInSheet = -1; |  | ||||||
|         bottomSheetController.animateTo( |  | ||||||
|           0.1, |  | ||||||
|           duration: const Duration(milliseconds: 200), |  | ||||||
|           curve: Curves.linearToEaseOut, |  | ||||||
|         ); |  | ||||||
|         isSheetScrolled.value = false; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     useEffect( |  | ||||||
|       () { |  | ||||||
|         final mapPageEventSubscription = |  | ||||||
|             widget.mapPageEventStream.listen(handleMapPageEvents); |  | ||||||
|         return mapPageEventSubscription.cancel; |  | ||||||
|       }, |  | ||||||
|       [widget.mapPageEventStream], |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     void handleVisibleItems(ItemPosition start, ItemPosition end) { |  | ||||||
|       final renderElement = _cachedRenderList?.elements[start.index]; |  | ||||||
|       if (renderElement == null) { |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       final rowOffset = renderElement.offset; |  | ||||||
|       if ((-start.itemLeadingEdge) != 0) { |  | ||||||
|         var columnOffset = -start.itemLeadingEdge ~/ 0.05; |  | ||||||
|         columnOffset = columnOffset < renderElement.totalCount |  | ||||||
|             ? columnOffset |  | ||||||
|             : renderElement.totalCount - 1; |  | ||||||
|         assetOffsetInSheet = rowOffset + columnOffset; |  | ||||||
|         final asset = _cachedRenderList?.allAssets?[assetOffsetInSheet]; |  | ||||||
|         userTappedOnMap = false; |  | ||||||
|         if (!userTappedOnMap && isSheetExpanded.value) { |  | ||||||
|           widget.bottomSheetEventSC.add( |  | ||||||
|             MapPageBottomSheetScrolled(asset), |  | ||||||
|           ); |  | ||||||
|         } |  | ||||||
|         if (isSheetExpanded.value) { |  | ||||||
|           isSheetScrolled.value = true; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void visibleItemsListener(ItemPosition start, ItemPosition end) { |  | ||||||
|       if (_cachedRenderList == null) { |  | ||||||
|         debounce.dispose(); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       debounce.call(() => handleVisibleItems(start, end)); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Widget buildNoPhotosWidget() { |  | ||||||
|       const image = Image( |  | ||||||
|         image: AssetImage('assets/lighthouse.png'), |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       return isSheetExpanded.value |  | ||||||
|           ? Column( |  | ||||||
|               children: [ |  | ||||||
|                 const SizedBox( |  | ||||||
|                   height: 80, |  | ||||||
|                 ), |  | ||||||
|                 SizedBox( |  | ||||||
|                   height: 150, |  | ||||||
|                   width: 150, |  | ||||||
|                   child: isDarkTheme |  | ||||||
|                       ? const InvertionFilter( |  | ||||||
|                           child: SaturationFilter( |  | ||||||
|                             saturation: -1, |  | ||||||
|                             child: BrightnessFilter( |  | ||||||
|                               brightness: -5, |  | ||||||
|                               child: image, |  | ||||||
|                             ), |  | ||||||
|                           ), |  | ||||||
|                         ) |  | ||||||
|                       : image, |  | ||||||
|                 ), |  | ||||||
|                 const SizedBox( |  | ||||||
|                   height: 20, |  | ||||||
|                 ), |  | ||||||
|                 Text( |  | ||||||
|                   "map_zoom_to_see_photos".tr(), |  | ||||||
|                   style: TextStyle( |  | ||||||
|                     fontSize: 20, |  | ||||||
|                     color: context.textTheme.displayLarge?.color, |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|             ) |  | ||||||
|           : const SizedBox.shrink(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void onTapMapButton() { |  | ||||||
|       if (assetOffsetInSheet != -1) { |  | ||||||
|         widget.bottomSheetEventSC.add( |  | ||||||
|           MapPageZoomToAsset( |  | ||||||
|             _cachedRenderList?.allAssets?[assetOffsetInSheet], |  | ||||||
|           ), |  | ||||||
|         ); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Widget buildDragHandle(ScrollController scrollController) { |  | ||||||
|       final textToDisplay = assetsInBound.value.isNotEmpty |  | ||||||
|           ? "map_assets_in_bounds" |  | ||||||
|               .tr(args: [assetsInBound.value.length.toString()]) |  | ||||||
|           : "map_no_assets_in_bounds".tr(); |  | ||||||
|       final dragHandle = Container( |  | ||||||
|         height: 70, |  | ||||||
|         width: double.infinity, |  | ||||||
|         decoration: BoxDecoration( |  | ||||||
|           color: isDarkTheme ? Colors.grey[900] : Colors.grey[100], |  | ||||||
|         ), |  | ||||||
|         child: Stack( |  | ||||||
|           children: [ |  | ||||||
|             Column( |  | ||||||
|               crossAxisAlignment: CrossAxisAlignment.center, |  | ||||||
|               mainAxisAlignment: MainAxisAlignment.center, |  | ||||||
|               children: [ |  | ||||||
|                 const SizedBox(height: 5), |  | ||||||
|                 const CustomDraggingHandle(), |  | ||||||
|                 const SizedBox(height: 15), |  | ||||||
|                 Text( |  | ||||||
|                   textToDisplay, |  | ||||||
|                   style: context.textTheme.bodyLarge, |  | ||||||
|                 ), |  | ||||||
|                 Divider( |  | ||||||
|                   height: 10, |  | ||||||
|                   color: |  | ||||||
|                       context.textTheme.displayLarge?.color?.withOpacity(0.5), |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|             if (isSheetExpanded.value && isSheetScrolled.value) |  | ||||||
|               Positioned( |  | ||||||
|                 top: 5, |  | ||||||
|                 right: 10, |  | ||||||
|                 child: IconButton( |  | ||||||
|                   icon: Icon( |  | ||||||
|                     Icons.map_outlined, |  | ||||||
|                     color: context.textTheme.displayLarge?.color, |  | ||||||
|                   ), |  | ||||||
|                   iconSize: 20, |  | ||||||
|                   tooltip: 'Zoom to bounds', |  | ||||||
|                   onPressed: onTapMapButton, |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|       return SingleChildScrollView( |  | ||||||
|         controller: scrollController, |  | ||||||
|         physics: const ClampingScrollPhysics(), |  | ||||||
|         child: dragHandle, |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return NotificationListener<DraggableScrollableNotification>( |  | ||||||
|       onNotification: (DraggableScrollableNotification notification) { |  | ||||||
|         final sheetExtended = notification.extent > 0.2; |  | ||||||
|         isSheetExpanded.value = sheetExtended; |  | ||||||
|         currentExtend.value = notification.extent; |  | ||||||
|         if (!sheetExtended) { |  | ||||||
|           // reset state |  | ||||||
|           userTappedOnMap = false; |  | ||||||
|           assetOffsetInSheet = -1; |  | ||||||
|           isSheetScrolled.value = false; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return true; |  | ||||||
|       }, |  | ||||||
|       child: Padding( |  | ||||||
|         padding: EdgeInsets.only( |  | ||||||
|           bottom: bottomPadding, |  | ||||||
|         ), |  | ||||||
|         child: Stack( |  | ||||||
|           children: [ |  | ||||||
|             DraggableScrollableSheet( |  | ||||||
|               controller: bottomSheetController, |  | ||||||
|               initialChildSize: 0.1, |  | ||||||
|               minChildSize: 0.1, |  | ||||||
|               maxChildSize: 0.55, |  | ||||||
|               snap: true, |  | ||||||
|               builder: ( |  | ||||||
|                 BuildContext context, |  | ||||||
|                 ScrollController scrollController, |  | ||||||
|               ) { |  | ||||||
|                 return Card( |  | ||||||
|                   color: isDarkTheme ? Colors.grey[900] : Colors.grey[100], |  | ||||||
|                   surfaceTintColor: Colors.transparent, |  | ||||||
|                   elevation: 18.0, |  | ||||||
|                   margin: const EdgeInsets.all(0), |  | ||||||
|                   child: Column( |  | ||||||
|                     children: [ |  | ||||||
|                       buildDragHandle(scrollController), |  | ||||||
|                       if (isSheetExpanded.value && |  | ||||||
|                           assetsInBound.value.isNotEmpty) |  | ||||||
|                         ref |  | ||||||
|                             .watch( |  | ||||||
|                               renderListProvider( |  | ||||||
|                                 assetsInBound.value, |  | ||||||
|                               ), |  | ||||||
|                             ) |  | ||||||
|                             .when( |  | ||||||
|                               data: (renderList) { |  | ||||||
|                                 _cachedRenderList = renderList; |  | ||||||
|                                 final assetGrid = ImmichAssetGrid( |  | ||||||
|                                   shrinkWrap: true, |  | ||||||
|                                   renderList: renderList, |  | ||||||
|                                   showDragScroll: false, |  | ||||||
|                                   selectionActive: widget.selectionEnabled, |  | ||||||
|                                   showMultiSelectIndicator: false, |  | ||||||
|                                   listener: widget.selectionlistener, |  | ||||||
|                                   visibleItemsListener: visibleItemsListener, |  | ||||||
|                                 ); |  | ||||||
|  |  | ||||||
|                                 return Expanded(child: assetGrid); |  | ||||||
|                               }, |  | ||||||
|                               error: (error, stackTrace) { |  | ||||||
|                                 log.warning( |  | ||||||
|                                   "Cannot get assets in the current map bounds ${error.toString()}", |  | ||||||
|                                   error, |  | ||||||
|                                   stackTrace, |  | ||||||
|                                 ); |  | ||||||
|                                 return const SizedBox.shrink(); |  | ||||||
|                               }, |  | ||||||
|                               loading: () => const SizedBox.shrink(), |  | ||||||
|                             ), |  | ||||||
|                       if (isSheetExpanded.value && assetsInBound.value.isEmpty) |  | ||||||
|                         Expanded( |  | ||||||
|                           child: SingleChildScrollView( |  | ||||||
|                             child: buildNoPhotosWidget(), |  | ||||||
|                           ), |  | ||||||
|                         ), |  | ||||||
|                     ], |  | ||||||
|                   ), |  | ||||||
|                 ); |  | ||||||
|               }, |  | ||||||
|             ), |  | ||||||
|             Positioned( |  | ||||||
|               bottom: maxHeight * currentExtend.value, |  | ||||||
|               left: 0, |  | ||||||
|               child: ColoredBox( |  | ||||||
|                 color: |  | ||||||
|                     (widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!, |  | ||||||
|                 child: Padding( |  | ||||||
|                   padding: const EdgeInsets.all(3), |  | ||||||
|                   child: Text( |  | ||||||
|                     'OpenStreetMap contributors', |  | ||||||
|                     style: TextStyle( |  | ||||||
|                       fontSize: 6, |  | ||||||
|                       color: !widget.isDarkTheme |  | ||||||
|                           ? Colors.grey[900] |  | ||||||
|                           : Colors.grey[100], |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|             Positioned( |  | ||||||
|               bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)), |  | ||||||
|               right: 15, |  | ||||||
|               child: ElevatedButton( |  | ||||||
|                 onPressed: () => widget.bottomSheetEventSC |  | ||||||
|                     .add(const MapPageZoomToLocation()), |  | ||||||
|                 style: ElevatedButton.styleFrom( |  | ||||||
|                   shape: const CircleBorder(), |  | ||||||
|                   padding: const EdgeInsets.all(12), |  | ||||||
|                 ), |  | ||||||
|                 child: const Icon( |  | ||||||
|                   Icons.my_location, |  | ||||||
|                   size: 22, |  | ||||||
|                   fill: 1, |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,228 +0,0 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; |  | ||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; |  | ||||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; |  | ||||||
|  |  | ||||||
| class MapSettingsDialog extends HookConsumerWidget { |  | ||||||
|   const MapSettingsDialog({super.key}); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |  | ||||||
|     final mapSettingsNotifier = ref.read(mapStateNotifier.notifier); |  | ||||||
|     final mapSettings = ref.read(mapStateNotifier); |  | ||||||
|     final isDarkMode = useState(mapSettings.isDarkTheme); |  | ||||||
|     final showFavoriteOnly = useState(mapSettings.showFavoriteOnly); |  | ||||||
|     final showIncludeArchived = useState(mapSettings.includeArchived); |  | ||||||
|     final showRelativeDate = useState(mapSettings.relativeTime); |  | ||||||
|     final ThemeData theme = context.themeData; |  | ||||||
|  |  | ||||||
|     Widget buildMapThemeSetting() { |  | ||||||
|       return SwitchListTile.adaptive( |  | ||||||
|         value: isDarkMode.value, |  | ||||||
|         onChanged: (value) { |  | ||||||
|           isDarkMode.value = value; |  | ||||||
|         }, |  | ||||||
|         activeColor: theme.primaryColor, |  | ||||||
|         dense: true, |  | ||||||
|         title: Text( |  | ||||||
|           "map_settings_dark_mode".tr(), |  | ||||||
|           style: |  | ||||||
|               theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Widget buildFavoriteOnlySetting() { |  | ||||||
|       return SwitchListTile.adaptive( |  | ||||||
|         value: showFavoriteOnly.value, |  | ||||||
|         onChanged: (value) { |  | ||||||
|           showFavoriteOnly.value = value; |  | ||||||
|         }, |  | ||||||
|         activeColor: theme.primaryColor, |  | ||||||
|         dense: true, |  | ||||||
|         title: Text( |  | ||||||
|           "map_settings_only_show_favorites".tr(), |  | ||||||
|           style: |  | ||||||
|               theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Widget buildIncludeArchivedSetting() { |  | ||||||
|       return SwitchListTile.adaptive( |  | ||||||
|         value: showIncludeArchived.value, |  | ||||||
|         onChanged: (value) { |  | ||||||
|           showIncludeArchived.value = value; |  | ||||||
|         }, |  | ||||||
|         activeColor: theme.primaryColor, |  | ||||||
|         dense: true, |  | ||||||
|         title: Text( |  | ||||||
|           "map_settings_include_show_archived".tr(), |  | ||||||
|           style: |  | ||||||
|               theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Widget buildDateRangeSetting() { |  | ||||||
|       final now = DateTime.now(); |  | ||||||
|       return DropdownMenu( |  | ||||||
|         enableSearch: false, |  | ||||||
|         enableFilter: false, |  | ||||||
|         initialSelection: showRelativeDate.value, |  | ||||||
|         onSelected: (value) { |  | ||||||
|           showRelativeDate.value = value!; |  | ||||||
|         }, |  | ||||||
|         dropdownMenuEntries: [ |  | ||||||
|           DropdownMenuEntry( |  | ||||||
|             value: 0, |  | ||||||
|             label: "map_settings_date_range_option_all".tr(), |  | ||||||
|           ), |  | ||||||
|           DropdownMenuEntry( |  | ||||||
|             value: 1, |  | ||||||
|             label: "map_settings_date_range_option_day".tr(), |  | ||||||
|           ), |  | ||||||
|           DropdownMenuEntry( |  | ||||||
|             value: 7, |  | ||||||
|             label: "map_settings_date_range_option_days".tr( |  | ||||||
|               args: ["7"], |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           DropdownMenuEntry( |  | ||||||
|             value: 30, |  | ||||||
|             label: "map_settings_date_range_option_days".tr( |  | ||||||
|               args: ["30"], |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           DropdownMenuEntry( |  | ||||||
|             value: now |  | ||||||
|                 .difference( |  | ||||||
|                   DateTime( |  | ||||||
|                     now.year - 1, |  | ||||||
|                     now.month, |  | ||||||
|                     now.day, |  | ||||||
|                     now.hour, |  | ||||||
|                     now.minute, |  | ||||||
|                     now.second, |  | ||||||
|                   ), |  | ||||||
|                 ) |  | ||||||
|                 .inDays, |  | ||||||
|             label: "map_settings_date_range_option_year".tr(), |  | ||||||
|           ), |  | ||||||
|           DropdownMenuEntry( |  | ||||||
|             value: now |  | ||||||
|                 .difference( |  | ||||||
|                   DateTime( |  | ||||||
|                     now.year - 3, |  | ||||||
|                     now.month, |  | ||||||
|                     now.day, |  | ||||||
|                     now.hour, |  | ||||||
|                     now.minute, |  | ||||||
|                     now.second, |  | ||||||
|                   ), |  | ||||||
|                 ) |  | ||||||
|                 .inDays, |  | ||||||
|             label: "map_settings_date_range_option_years".tr(args: ["3"]), |  | ||||||
|           ), |  | ||||||
|         ], |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     List<Widget> getDialogActions() { |  | ||||||
|       return <Widget>[ |  | ||||||
|         TextButton( |  | ||||||
|           onPressed: () => context.pop(), |  | ||||||
|           style: TextButton.styleFrom( |  | ||||||
|             backgroundColor: |  | ||||||
|                 mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700], |  | ||||||
|           ), |  | ||||||
|           child: Padding( |  | ||||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), |  | ||||||
|             child: Text( |  | ||||||
|               "map_settings_dialog_cancel".tr(), |  | ||||||
|               style: theme.textTheme.labelLarge?.copyWith( |  | ||||||
|                 fontWeight: FontWeight.w500, |  | ||||||
|                 color: mapSettings.isDarkTheme |  | ||||||
|                     ? Colors.grey[900] |  | ||||||
|                     : Colors.grey[100], |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|         TextButton( |  | ||||||
|           onPressed: () { |  | ||||||
|             mapSettingsNotifier.switchTheme(isDarkMode.value); |  | ||||||
|             mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value); |  | ||||||
|             mapSettingsNotifier.setRelativeTime(showRelativeDate.value); |  | ||||||
|             mapSettingsNotifier |  | ||||||
|                 .switchIncludeArchived(showIncludeArchived.value); |  | ||||||
|             context.pop(); |  | ||||||
|           }, |  | ||||||
|           style: TextButton.styleFrom( |  | ||||||
|             backgroundColor: theme.primaryColor, |  | ||||||
|           ), |  | ||||||
|           child: Padding( |  | ||||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), |  | ||||||
|             child: Text( |  | ||||||
|               "map_settings_dialog_save".tr(), |  | ||||||
|               style: theme.textTheme.labelLarge?.copyWith( |  | ||||||
|                 fontWeight: FontWeight.w500, |  | ||||||
|                 color: theme.primaryTextTheme.labelLarge?.color, |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ]; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return AlertDialog( |  | ||||||
|       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), |  | ||||||
|       title: Center( |  | ||||||
|         child: Text( |  | ||||||
|           "map_settings_dialog_title".tr(), |  | ||||||
|           style: TextStyle( |  | ||||||
|             color: theme.primaryColor, |  | ||||||
|             fontWeight: FontWeight.bold, |  | ||||||
|             fontSize: 18, |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|       content: SizedBox( |  | ||||||
|         width: double.maxFinite, |  | ||||||
|         child: ConstrainedBox( |  | ||||||
|           constraints: BoxConstraints( |  | ||||||
|             maxHeight: context.height * 0.6, |  | ||||||
|           ), |  | ||||||
|           child: ListView( |  | ||||||
|             shrinkWrap: true, |  | ||||||
|             children: [ |  | ||||||
|               buildMapThemeSetting(), |  | ||||||
|               buildFavoriteOnlySetting(), |  | ||||||
|               buildIncludeArchivedSetting(), |  | ||||||
|               const SizedBox( |  | ||||||
|                 height: 10, |  | ||||||
|               ), |  | ||||||
|               Padding( |  | ||||||
|                 padding: const EdgeInsets.only(left: 20), |  | ||||||
|                 child: Column( |  | ||||||
|                   crossAxisAlignment: CrossAxisAlignment.start, |  | ||||||
|                   children: [ |  | ||||||
|                     Text( |  | ||||||
|                       "map_settings_only_relative_range".tr(), |  | ||||||
|                       style: const TextStyle(fontWeight: FontWeight.bold), |  | ||||||
|                     ), |  | ||||||
|                     buildDateRangeSetting(), |  | ||||||
|                   ], |  | ||||||
|                 ), |  | ||||||
|               ), |  | ||||||
|             ].toList(), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|       actions: getDialogActions(), |  | ||||||
|       actionsAlignment: MainAxisAlignment.spaceEvenly, |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,86 +0,0 @@ | |||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
| import 'package:flutter_map/plugin_api.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; |  | ||||||
| import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart'; |  | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
| import 'package:url_launcher/url_launcher.dart'; |  | ||||||
|  |  | ||||||
| // A non-interactive thumbnail of a map in the given coordinates with optional markers |  | ||||||
| class MapThumbnail extends HookConsumerWidget { |  | ||||||
|   final Function(TapPosition, LatLng)? onTap; |  | ||||||
|   final LatLng coords; |  | ||||||
|   final double zoom; |  | ||||||
|   final List<Marker> markers; |  | ||||||
|   final double height; |  | ||||||
|   final double width; |  | ||||||
|   final bool showAttribution; |  | ||||||
|   final bool isDarkTheme; |  | ||||||
|  |  | ||||||
|   const MapThumbnail({ |  | ||||||
|     super.key, |  | ||||||
|     required this.coords, |  | ||||||
|     this.height = 100, |  | ||||||
|     this.width = 100, |  | ||||||
|     this.onTap, |  | ||||||
|     this.zoom = 1, |  | ||||||
|     this.showAttribution = true, |  | ||||||
|     this.isDarkTheme = false, |  | ||||||
|     this.markers = const [], |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |  | ||||||
|     final mapController = useMapController(); |  | ||||||
|     final isMapReady = useRef(false); |  | ||||||
|     ref.watch(mapStateNotifier.select((s) => s.mapStyle)); |  | ||||||
|  |  | ||||||
|     useEffect( |  | ||||||
|       () { |  | ||||||
|         if (isMapReady.value && mapController.center != coords) { |  | ||||||
|           mapController.move(coords, zoom); |  | ||||||
|         } |  | ||||||
|         return null; |  | ||||||
|       }, |  | ||||||
|       [coords], |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     return SizedBox( |  | ||||||
|       height: height, |  | ||||||
|       width: width, |  | ||||||
|       child: ClipRRect( |  | ||||||
|         borderRadius: const BorderRadius.all(Radius.circular(15)), |  | ||||||
|         child: FlutterMap( |  | ||||||
|           mapController: mapController, |  | ||||||
|           options: MapOptions( |  | ||||||
|             interactiveFlags: InteractiveFlag.none, |  | ||||||
|             center: coords, |  | ||||||
|             zoom: zoom, |  | ||||||
|             onTap: onTap, |  | ||||||
|             onMapReady: () => isMapReady.value = true, |  | ||||||
|           ), |  | ||||||
|           nonRotatedChildren: [ |  | ||||||
|             if (showAttribution) |  | ||||||
|               RichAttributionWidget( |  | ||||||
|                 animationConfig: const ScaleRAWA(), |  | ||||||
|                 attributions: [ |  | ||||||
|                   TextSourceAttribution( |  | ||||||
|                     'OpenStreetMap contributors', |  | ||||||
|                     onTap: () => launchUrl( |  | ||||||
|                       Uri.parse('https://openstreetmap.org/copyright'), |  | ||||||
|                       mode: LaunchMode.externalApplication, |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ], |  | ||||||
|               ), |  | ||||||
|           ], |  | ||||||
|           children: [ |  | ||||||
|             ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme), |  | ||||||
|             if (markers.isNotEmpty) MarkerLayer(markers: markers), |  | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,32 +0,0 @@ | |||||||
| import 'package:flutter/widgets.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; |  | ||||||
| import 'package:flutter_map/flutter_map.dart'; |  | ||||||
|  |  | ||||||
| MapController useMapController({ |  | ||||||
|   String? debugLabel, |  | ||||||
|   List<Object?>? keys, |  | ||||||
| }) { |  | ||||||
|   return use(_MapControllerHook(keys: keys)); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _MapControllerHook extends Hook<MapController> { |  | ||||||
|   const _MapControllerHook({List<Object?>? keys}) : super(keys: keys); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   HookState<MapController, Hook<MapController>> createState() => |  | ||||||
|       _MapControllerHookState(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _MapControllerHookState |  | ||||||
|     extends HookState<MapController, _MapControllerHook> { |  | ||||||
|   late final controller = MapController(); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   MapController build(BuildContext context) => controller; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() => controller.dispose(); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   String get debugLabel => 'useMapController'; |  | ||||||
| } |  | ||||||
							
								
								
									
										138
									
								
								mobile/lib/modules/map/utils/map_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								mobile/lib/modules/map/utils/map_utils.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/models/map_marker.dart'; | ||||||
|  | import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; | ||||||
|  | import 'package:geolocator/geolocator.dart'; | ||||||
|  | import 'package:logging/logging.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
|  | class MapUtils { | ||||||
|  |   MapUtils._(); | ||||||
|  |  | ||||||
|  |   static final Logger _log = Logger("MapUtils"); | ||||||
|  |   static const defaultSourceId = 'asset-map-markers'; | ||||||
|  |   static const defaultHeatMapLayerId = 'asset-heatmap-layer'; | ||||||
|  |  | ||||||
|  |   static const defaultHeatMapLayerProperties = HeatmapLayerProperties( | ||||||
|  |     heatmapColor: [ | ||||||
|  |       Expressions.interpolate, | ||||||
|  |       ["linear"], | ||||||
|  |       ["heatmap-density"], | ||||||
|  |       0.0, | ||||||
|  |       "rgba(246,239,247,0.0)", | ||||||
|  |       0.2, | ||||||
|  |       "rgb(208,209,230)", | ||||||
|  |       0.4, | ||||||
|  |       "rgb(166,189,219)", | ||||||
|  |       0.6, | ||||||
|  |       "rgb(103,169,207)", | ||||||
|  |       0.8, | ||||||
|  |       "rgb(28,144,153)", | ||||||
|  |       1.0, | ||||||
|  |       "rgb(1,108,89)", | ||||||
|  |     ], | ||||||
|  |     heatmapIntensity: [ | ||||||
|  |       Expressions.interpolate, ["linear"], // | ||||||
|  |       [Expressions.zoom], | ||||||
|  |       0, 0.5, | ||||||
|  |       9, 2, | ||||||
|  |     ], | ||||||
|  |     heatmapRadius: [ | ||||||
|  |       Expressions.interpolate, ["linear"], // | ||||||
|  |       [Expressions.zoom], | ||||||
|  |       0, 4, | ||||||
|  |       4, 8, | ||||||
|  |       9, 16, | ||||||
|  |     ], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   static Map<String, dynamic> _addFeature(MapMarker marker) => { | ||||||
|  |         'type': 'Feature', | ||||||
|  |         'id': marker.assetRemoteId, | ||||||
|  |         'geometry': { | ||||||
|  |           'type': 'Point', | ||||||
|  |           'coordinates': [marker.latLng.longitude, marker.latLng.latitude], | ||||||
|  |         }, | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |   static Map<String, dynamic> generateGeoJsonForMarkers( | ||||||
|  |     List<MapMarker> markers, | ||||||
|  |   ) => | ||||||
|  |       { | ||||||
|  |         'type': 'FeatureCollection', | ||||||
|  |         'features': markers.map(_addFeature).toList(), | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |   static Future<(Position?, LocationPermission?)> checkPermAndGetLocation( | ||||||
|  |     BuildContext context, | ||||||
|  |   ) async { | ||||||
|  |     try { | ||||||
|  |       bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); | ||||||
|  |       if (!serviceEnabled) { | ||||||
|  |         showDialog( | ||||||
|  |           context: context, | ||||||
|  |           builder: (context) => _LocationServiceDisabledDialog(), | ||||||
|  |         ); | ||||||
|  |         return (null, LocationPermission.deniedForever); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       LocationPermission permission = await Geolocator.checkPermission(); | ||||||
|  |       bool shouldRequestPermission = false; | ||||||
|  |  | ||||||
|  |       if (permission == LocationPermission.denied) { | ||||||
|  |         shouldRequestPermission = await showDialog( | ||||||
|  |           context: context, | ||||||
|  |           builder: (context) => _LocationPermissionDisabledDialog(), | ||||||
|  |         ); | ||||||
|  |         if (shouldRequestPermission) { | ||||||
|  |           permission = await Geolocator.requestPermission(); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (permission == LocationPermission.denied || | ||||||
|  |           permission == LocationPermission.deniedForever) { | ||||||
|  |         // Open app settings only if you did not request for permission before | ||||||
|  |         if (permission == LocationPermission.deniedForever && | ||||||
|  |             !shouldRequestPermission) { | ||||||
|  |           await Geolocator.openAppSettings(); | ||||||
|  |         } | ||||||
|  |         return (null, LocationPermission.deniedForever); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       Position currentUserLocation = await Geolocator.getCurrentPosition( | ||||||
|  |         desiredAccuracy: LocationAccuracy.medium, | ||||||
|  |         timeLimit: const Duration(seconds: 5), | ||||||
|  |       ); | ||||||
|  |       return (currentUserLocation, null); | ||||||
|  |     } catch (error) { | ||||||
|  |       _log.severe( | ||||||
|  |         "Cannot get user's current location due to ${error.toString()}", | ||||||
|  |       ); | ||||||
|  |       return (null, LocationPermission.unableToDetermine); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _LocationServiceDisabledDialog extends ConfirmDialog { | ||||||
|  |   _LocationServiceDisabledDialog() | ||||||
|  |       : super( | ||||||
|  |           title: 'map_location_service_disabled_title'.tr(), | ||||||
|  |           content: 'map_location_service_disabled_content'.tr(), | ||||||
|  |           cancel: 'map_location_dialog_cancel'.tr(), | ||||||
|  |           ok: 'map_location_dialog_yes'.tr(), | ||||||
|  |           onOk: () async { | ||||||
|  |             await Geolocator.openLocationSettings(); | ||||||
|  |           }, | ||||||
|  |         ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _LocationPermissionDisabledDialog extends ConfirmDialog { | ||||||
|  |   _LocationPermissionDisabledDialog() | ||||||
|  |       : super( | ||||||
|  |           title: 'map_no_location_permission_title'.tr(), | ||||||
|  |           content: 'map_no_location_permission_content'.tr(), | ||||||
|  |           cancel: 'map_location_dialog_cancel'.tr(), | ||||||
|  |           ok: 'map_location_dialog_yes'.tr(), | ||||||
|  |           onOk: () {}, | ||||||
|  |         ); | ||||||
|  | } | ||||||
							
								
								
									
										185
									
								
								mobile/lib/modules/map/views/map_location_picker_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								mobile/lib/modules/map/views/map_location_picker_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | |||||||
|  | import 'dart:math'; | ||||||
|  |  | ||||||
|  | import 'package:auto_route/auto_route.dart'; | ||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.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/maplibrecontroller_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/utils/map_utils.dart'; | ||||||
|  | import 'package:geolocator/geolocator.dart'; | ||||||
|  |  | ||||||
|  | class MapLocationPickerPage extends HookConsumerWidget { | ||||||
|  |   final LatLng initialLatLng; | ||||||
|  |  | ||||||
|  |   const MapLocationPickerPage({ | ||||||
|  |     super.key, | ||||||
|  |     this.initialLatLng = const LatLng(0, 0), | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final selectedLatLng = useValueNotifier<LatLng>(initialLatLng); | ||||||
|  |     final controller = useRef<MaplibreMapController?>(null); | ||||||
|  |     final marker = useRef<Symbol?>(null); | ||||||
|  |  | ||||||
|  |     Future<void> onStyleLoaded() async { | ||||||
|  |       marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Future<void> onMapClick(Point<num> point, LatLng centre) async { | ||||||
|  |       selectedLatLng.value = centre; | ||||||
|  |       controller.value?.animateCamera(CameraUpdate.newLatLng(centre)); | ||||||
|  |       if (marker.value != null) { | ||||||
|  |         await controller.value | ||||||
|  |             ?.updateSymbol(marker.value!, SymbolOptions(geometry: centre)); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     void onClose([LatLng? selected]) { | ||||||
|  |       context.popRoute(selected); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Future<void> getCurrentLocation() async { | ||||||
|  |       var (currentLocation, locationPermission)  = await MapUtils.checkPermAndGetLocation(context); | ||||||
|  |       if (locationPermission == LocationPermission.denied || | ||||||
|  |           locationPermission == LocationPermission.deniedForever) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       if (currentLocation == null) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude); | ||||||
|  |       selectedLatLng.value = currentLatLng; | ||||||
|  |       controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return MapThemeOveride( | ||||||
|  |       mapBuilder: (style) => Builder( | ||||||
|  |         builder: (ctx) => Scaffold( | ||||||
|  |           backgroundColor: ctx.themeData.cardColor, | ||||||
|  |           appBar: _AppBar(onClose: onClose), | ||||||
|  |           extendBodyBehindAppBar: true, | ||||||
|  |           body: Column( | ||||||
|  |             children: [ | ||||||
|  |               style.widgetWhen( | ||||||
|  |                 onData: (style) => Expanded( | ||||||
|  |                   child: Container( | ||||||
|  |                     clipBehavior: Clip.antiAliasWithSaveLayer, | ||||||
|  |                     decoration: const BoxDecoration( | ||||||
|  |                       borderRadius: BorderRadius.only( | ||||||
|  |                         bottomLeft: Radius.circular(40), | ||||||
|  |                         bottomRight: Radius.circular(40), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                     child: MaplibreMap( | ||||||
|  |                       initialCameraPosition: | ||||||
|  |                           CameraPosition(target: initialLatLng, zoom: 12), | ||||||
|  |                       styleString: style, | ||||||
|  |                       onMapCreated: (mapController) => | ||||||
|  |                           controller.value = mapController, | ||||||
|  |                       onStyleLoadedCallback: onStyleLoaded, | ||||||
|  |                       onMapClick: onMapClick, | ||||||
|  |                       dragEnabled: false, | ||||||
|  |                       tiltGesturesEnabled: false, | ||||||
|  |                       myLocationEnabled: false, | ||||||
|  |                       attributionButtonMargins: const Point(20, 15), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               _BottomBar( | ||||||
|  |                 selectedLatLng: selectedLatLng, | ||||||
|  |                 onUseLocation: () => onClose(selectedLatLng.value), | ||||||
|  |                 onGetCurrentLocation: getCurrentLocation, | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _AppBar extends StatelessWidget implements PreferredSizeWidget { | ||||||
|  |   final Function() onClose; | ||||||
|  |  | ||||||
|  |   const _AppBar({required this.onClose}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Padding( | ||||||
|  |       padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25), | ||||||
|  |       child: Expanded( | ||||||
|  |         child: Align( | ||||||
|  |           alignment: Alignment.centerLeft, | ||||||
|  |           child: ElevatedButton( | ||||||
|  |             onPressed: onClose, | ||||||
|  |             style: ElevatedButton.styleFrom( | ||||||
|  |               shape: const CircleBorder(), | ||||||
|  |             ), | ||||||
|  |             child: const Icon(Icons.arrow_back_ios_new_rounded), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Size get preferredSize => const Size.fromHeight(100); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _BottomBar extends StatelessWidget { | ||||||
|  |   final ValueNotifier<LatLng> selectedLatLng; | ||||||
|  |   final Function() onUseLocation; | ||||||
|  |   final Function() onGetCurrentLocation; | ||||||
|  |  | ||||||
|  |   const _BottomBar({ | ||||||
|  |     required this.selectedLatLng, | ||||||
|  |     required this.onUseLocation, | ||||||
|  |     required this.onGetCurrentLocation, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return SizedBox( | ||||||
|  |       height: 150, | ||||||
|  |       child: Column( | ||||||
|  |         mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|  |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |         children: [ | ||||||
|  |           Row( | ||||||
|  |             mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |             children: [ | ||||||
|  |               const Icon(Icons.public, size: 18), | ||||||
|  |               const SizedBox(width: 15), | ||||||
|  |               ValueListenableBuilder( | ||||||
|  |                 valueListenable: selectedLatLng, | ||||||
|  |                 builder: (_, value, __) => Text( | ||||||
|  |                   "${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}", | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |           Row( | ||||||
|  |             mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|  |             children: [ | ||||||
|  |               ElevatedButton( | ||||||
|  |                 onPressed: onUseLocation, | ||||||
|  |                 child: const Text("map_location_picker_page_use_location").tr(), | ||||||
|  |               ), | ||||||
|  |               ElevatedButton( | ||||||
|  |                 onPressed: onGetCurrentLocation, | ||||||
|  |                 child: const Icon(Icons.my_location), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,250 +1,225 @@ | |||||||
| import 'dart:async'; | import 'dart:math'; | ||||||
| import 'dart:math' as math; |  | ||||||
|  |  | ||||||
| import 'package:auto_route/auto_route.dart'; | import 'package:auto_route/auto_route.dart'; | ||||||
| import 'package:collection/collection.dart'; | import 'package:collection/collection.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:flutter_map/plugin_api.dart'; |  | ||||||
| import 'package:flutter_map_heatmap/flutter_map_heatmap.dart'; |  | ||||||
| import 'package:fluttertoast/fluttertoast.dart'; | import 'package:fluttertoast/fluttertoast.dart'; | ||||||
| import 'package:geolocator/geolocator.dart'; | import 'package:geolocator/geolocator.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.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/build_context_extensions.dart'; | ||||||
| import 'package:immich_mobile/modules/map/models/map_page_event.model.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_marker.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart'; | import 'package:immich_mobile/modules/map/utils/map_utils.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/location_dialog.dart'; | import 'package:immich_mobile/modules/map/widgets/map_app_bar.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart'; | import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/map_page_app_bar.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/routing/router.dart'; | ||||||
| import 'package:immich_mobile/shared/models/asset.dart'; | import 'package:immich_mobile/shared/models/asset.dart'; | ||||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||||
| import 'package:immich_mobile/shared/ui/immich_toast.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:immich_mobile/utils/debounce.dart'; | ||||||
| import 'package:immich_mobile/extensions/flutter_map_extensions.dart'; | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
| import 'package:immich_mobile/utils/immich_app_theme.dart'; |  | ||||||
| import 'package:immich_mobile/utils/selection_handlers.dart'; |  | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
| import 'package:logging/logging.dart'; |  | ||||||
|  |  | ||||||
| class MapPage extends StatefulHookConsumerWidget { | class MapPage extends HookConsumerWidget { | ||||||
|   const MapPage({super.key}); |   const MapPage({super.key}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   MapPageState createState() => MapPageState(); |   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; | ||||||
|  |  | ||||||
| class MapPageState extends ConsumerState<MapPage> { |     // updates the markersInBounds value with the map markers that are visible in the current | ||||||
|   // Non-State variables |     // map camera bounds | ||||||
|   late final MapController mapController; |     Future<void> updateAssetsInBounds() async { | ||||||
|   // Streams are used instead of callbacks to prevent unnecessary rebuilds on events |       // Guard map not created | ||||||
|   final StreamController mapPageEventSC = |       if (mapController.value == null) { | ||||||
|       StreamController<MapPageEventBase>.broadcast(); |         return; | ||||||
|   final StreamController bottomSheetEventSC = |  | ||||||
|       StreamController<MapPageEventBase>.broadcast(); |  | ||||||
|   late final Stream bottomSheetEventStream; |  | ||||||
|   // Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet |  | ||||||
|   // resulting in it getting reloaded each time a map move occurs |  | ||||||
|   Set<AssetMarkerData> assetsInBounds = {}; |  | ||||||
|   // TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded |  | ||||||
|   // https://github.com/fleaflet/flutter_map/issues/1542 |  | ||||||
|   // The below is used instead of MapEventMove#id to handle event from controller |  | ||||||
|   // in onMapEvent() since MapEventMove#id is not populated properly in the |  | ||||||
|   // current version of flutter_map(4.0.0) used |  | ||||||
|   bool forceAssetUpdate = false; |  | ||||||
|   bool isMapReady = false; |  | ||||||
|   late final Debounce debounce; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void initState() { |  | ||||||
|     super.initState(); |  | ||||||
|     mapController = MapController(); |  | ||||||
|     bottomSheetEventStream = bottomSheetEventSC.stream; |  | ||||||
|     // Map zoom events and move events are triggered often. Throttle the call to limit rebuilds |  | ||||||
|     debounce = Debounce( |  | ||||||
|       const Duration(milliseconds: 300), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     debounce.dispose(); |  | ||||||
|     super.dispose(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void reloadAssetsInBound( |  | ||||||
|     Set<AssetMarkerData>? assetMarkers, { |  | ||||||
|     bool forceReload = false, |  | ||||||
|   }) { |  | ||||||
|     try { |  | ||||||
|       final bounds = isMapReady ? mapController.bounds : null; |  | ||||||
|       if (bounds != null) { |  | ||||||
|         final oldAssetsInBounds = assetsInBounds.toSet(); |  | ||||||
|         assetsInBounds = |  | ||||||
|             assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {}; |  | ||||||
|         final shouldReload = forceReload || |  | ||||||
|             assetsInBounds.difference(oldAssetsInBounds).isNotEmpty || |  | ||||||
|             assetsInBounds.length != oldAssetsInBounds.length; |  | ||||||
|         if (shouldReload) { |  | ||||||
|           mapPageEventSC.add( |  | ||||||
|             MapPageAssetsInBoundUpdated( |  | ||||||
|               assetsInBounds.map((e) => e.asset).toList(), |  | ||||||
|             ), |  | ||||||
|           ); |  | ||||||
|         } |  | ||||||
|       } |       } | ||||||
|     } finally { |  | ||||||
|       // Consume all error |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void openAssetInViewer(Asset asset) { |       final bounds = await mapController.value!.getVisibleRegion(); | ||||||
|     context.pushRoute( |       final inBounds = markers.value | ||||||
|       GalleryViewerRoute( |           .where( | ||||||
|         initialIndex: 0, |             (m) => | ||||||
|         loadAsset: (index) => asset, |                 bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)), | ||||||
|         totalAssets: 1, |           ) | ||||||
|         heroOffset: 0, |           .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; | ||||||
|  |       }, | ||||||
|  |       [], | ||||||
|     ); |     ); | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |     // Refetch markers when map state is changed | ||||||
|   Widget build(BuildContext context) { |     ref.listen(mapStateNotifierProvider, (_, current) { | ||||||
|     final log = Logger("MapService"); |       if (current.shouldRefetchMarkers) { | ||||||
|     final isDarkTheme = |         markerDebouncer.run(() { | ||||||
|         ref.watch(mapStateNotifier.select((state) => state.isDarkTheme)); |           ref.invalidate(mapMarkersProvider); | ||||||
|     final ValueNotifier<Set<AssetMarkerData>> mapMarkerData = |           // Reset marker | ||||||
|         useState(<AssetMarkerData>{}); |           selectedMarker.value = null; | ||||||
|     final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null); |           loadMarkers(); | ||||||
|     final selectionEnabledHook = useState(false); |           ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false); | ||||||
|     final selectedAssets = useState(<Asset>{}); |         }); | ||||||
|     final showLoadingIndicator = useState(false); |  | ||||||
|     final refetchMarkers = useState(true); |  | ||||||
|     final isLoading = |  | ||||||
|         ref.watch(mapStateNotifier.select((state) => state.isLoading)); |  | ||||||
|     final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom; |  | ||||||
|     final zoomLevel = math.min(maxZoom, 14.0); |  | ||||||
|  |  | ||||||
|     if (refetchMarkers.value) { |  | ||||||
|       mapMarkerData.value = ref.watch(mapMarkersProvider).when( |  | ||||||
|             skipLoadingOnRefresh: false, |  | ||||||
|             error: (error, stackTrace) { |  | ||||||
|               log.warning( |  | ||||||
|                 "Cannot get map markers ${error.toString()}", |  | ||||||
|                 error, |  | ||||||
|                 stackTrace, |  | ||||||
|               ); |  | ||||||
|               showLoadingIndicator.value = false; |  | ||||||
|               return {}; |  | ||||||
|             }, |  | ||||||
|             loading: () { |  | ||||||
|               showLoadingIndicator.value = true; |  | ||||||
|               return {}; |  | ||||||
|             }, |  | ||||||
|             data: (data) { |  | ||||||
|               showLoadingIndicator.value = false; |  | ||||||
|               refetchMarkers.value = false; |  | ||||||
|               closestAssetMarker.value = null; |  | ||||||
|               debounce( |  | ||||||
|                 () => reloadAssetsInBound( |  | ||||||
|                   mapMarkerData.value, |  | ||||||
|                   forceReload: true, |  | ||||||
|                 ), |  | ||||||
|               ); |  | ||||||
|               return data; |  | ||||||
|             }, |  | ||||||
|           ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     ref.listen(mapStateNotifier, (previous, next) { |  | ||||||
|       bool shouldRefetch = |  | ||||||
|           previous?.showFavoriteOnly != next.showFavoriteOnly || |  | ||||||
|               previous?.relativeTime != next.relativeTime || |  | ||||||
|               previous?.includeArchived != next.includeArchived; |  | ||||||
|       if (shouldRefetch) { |  | ||||||
|         refetchMarkers.value = shouldRefetch; |  | ||||||
|         ref.invalidate(mapMarkersProvider); |  | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     void onZoomToAssetEvent(Asset? assetInBottomSheet) { |     // updates the selected markers position based on the current map camera | ||||||
|       if (assetInBottomSheet != null) { |     Future<void> updateAssetMarkerPosition( | ||||||
|         final mapMarker = mapMarkerData.value |       MapMarker marker, { | ||||||
|             .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id); |       bool shouldAnimate = true, | ||||||
|         if (mapMarker != null) { |     }) async { | ||||||
|           LatLng? newCenter = mapController.centerBoundsWithPadding( |       final assetPoint = | ||||||
|             mapMarker.point, |           await mapController.value!.toScreenLocation(marker.latLng); | ||||||
|             const Offset(0, -120), |       selectedMarker.value = _AssetMarkerMeta( | ||||||
|             zoomLevel: zoomLevel, |         point: assetPoint, | ||||||
|           ); |         marker: marker, | ||||||
|           if (newCenter != null) { |         shouldAnimate: shouldAnimate, | ||||||
|             forceAssetUpdate = true; |       ); | ||||||
|             mapController.move(newCenter, zoomLevel); |       (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 { |     void onZoomToLocation() async { | ||||||
|       try { |       final location = await MapUtils.checkPermAndGetLocation(context); | ||||||
|         bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); |       if (location.$2 != null) { | ||||||
|         if (!serviceEnabled) { |         if (location.$2 == LocationPermission.unableToDetermine && | ||||||
|           showDialog( |             context.mounted) { | ||||||
|             context: context, |  | ||||||
|             builder: (context) => Theme( |  | ||||||
|               data: isDarkTheme ? immichDarkTheme : immichLightTheme, |  | ||||||
|               child: LocationServiceDisabledDialog(), |  | ||||||
|             ), |  | ||||||
|           ); |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         LocationPermission permission = await Geolocator.checkPermission(); |  | ||||||
|         bool shouldRequestPermission = false; |  | ||||||
|  |  | ||||||
|         if (permission == LocationPermission.denied) { |  | ||||||
|           shouldRequestPermission = await showDialog( |  | ||||||
|             context: context, |  | ||||||
|             builder: (context) => Theme( |  | ||||||
|               data: isDarkTheme ? immichDarkTheme : immichLightTheme, |  | ||||||
|               child: LocationPermissionDisabledDialog(), |  | ||||||
|             ), |  | ||||||
|           ); |  | ||||||
|           if (shouldRequestPermission) { |  | ||||||
|             permission = await Geolocator.requestPermission(); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (permission == LocationPermission.denied || |  | ||||||
|             permission == LocationPermission.deniedForever) { |  | ||||||
|           // Open app settings only if you did not request for permission before |  | ||||||
|           if (permission == LocationPermission.deniedForever && |  | ||||||
|               !shouldRequestPermission) { |  | ||||||
|             await Geolocator.openAppSettings(); |  | ||||||
|           } |  | ||||||
|           return; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Position currentUserLocation = await Geolocator.getCurrentPosition( |  | ||||||
|           desiredAccuracy: LocationAccuracy.medium, |  | ||||||
|           timeLimit: const Duration(seconds: 5), |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         forceAssetUpdate = true; |  | ||||||
|         mapController.move( |  | ||||||
|           LatLng(currentUserLocation.latitude, currentUserLocation.longitude), |  | ||||||
|           zoomLevel, |  | ||||||
|         ); |  | ||||||
|       } catch (error) { |  | ||||||
|         log.severe( |  | ||||||
|           "Cannot get user's current location due to ${error.toString()}", |  | ||||||
|         ); |  | ||||||
|         if (context.mounted) { |  | ||||||
|           ImmichToast.show( |           ImmichToast.show( | ||||||
|             context: context, |             context: context, | ||||||
|             gravity: ToastGravity.BOTTOM, |             gravity: ToastGravity.BOTTOM, | ||||||
| @@ -252,253 +227,180 @@ class MapPageState extends ConsumerState<MapPage> { | |||||||
|             msg: "map_cannot_get_user_location".tr(), |             msg: "map_cannot_get_user_location".tr(), | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|  |         return; | ||||||
|       } |       } | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void handleBottomSheetEvents(dynamic event) { |       if (mapController.value != null && location.$1 != null) { | ||||||
|       if (event is MapPageBottomSheetScrolled) { |         mapController.value!.animateCamera( | ||||||
|         final assetInBottomSheet = event.asset; |           CameraUpdate.newLatLngZoom( | ||||||
|         if (assetInBottomSheet != null) { |             LatLng(location.$1!.latitude, location.$1!.longitude), | ||||||
|           final mapMarker = mapMarkerData.value |             mapZoomToAssetLevel, | ||||||
|               .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id); |           ), | ||||||
|           closestAssetMarker.value = mapMarker; |           duration: const Duration(milliseconds: 800), | ||||||
|           if (mapMarker != null && mapController.zoom >= 5) { |  | ||||||
|             LatLng? newCenter = mapController.centerBoundsWithPadding( |  | ||||||
|               mapMarker.point, |  | ||||||
|               const Offset(0, -120), |  | ||||||
|             ); |  | ||||||
|             if (newCenter != null) { |  | ||||||
|               mapController.move( |  | ||||||
|                 newCenter, |  | ||||||
|                 mapController.zoom, |  | ||||||
|               ); |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } else if (event is MapPageZoomToAsset) { |  | ||||||
|         onZoomToAssetEvent(event.asset); |  | ||||||
|       } else if (event is MapPageZoomToLocation) { |  | ||||||
|         onZoomToLocation(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     useEffect( |  | ||||||
|       () { |  | ||||||
|         final bottomSheetEventSubscription = |  | ||||||
|             bottomSheetEventStream.listen(handleBottomSheetEvents); |  | ||||||
|         return bottomSheetEventSubscription.cancel; |  | ||||||
|       }, |  | ||||||
|       [bottomSheetEventStream], |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     void handleMapTapEvent(LatLng tapPosition) { |  | ||||||
|       const d = Distance(); |  | ||||||
|       final assetsInBoundsList = assetsInBounds.toList(); |  | ||||||
|       assetsInBoundsList.sort( |  | ||||||
|         (a, b) => d |  | ||||||
|             .distance(a.point, tapPosition) |  | ||||||
|             .compareTo(d.distance(b.point, tapPosition)), |  | ||||||
|       ); |  | ||||||
|       // First asset less than the threshold from the tap point |  | ||||||
|       final nearestAsset = assetsInBoundsList.firstWhereOrNull( |  | ||||||
|         (element) => |  | ||||||
|             d.distance(element.point, tapPosition) < |  | ||||||
|             mapController.getTapThresholdForZoomLevel(), |  | ||||||
|       ); |  | ||||||
|       // Reset marker if no assets are near the tap point |  | ||||||
|       if (nearestAsset == null && closestAssetMarker.value != null) { |  | ||||||
|         selectionEnabledHook.value = false; |  | ||||||
|         mapPageEventSC.add( |  | ||||||
|           const MapPageOnTapEvent(), |  | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|       closestAssetMarker.value = nearestAsset; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     void onMapEvent(MapEvent mapEvent) { |     void onAssetsSelected(bool selected, Set<Asset> selection) { | ||||||
|       if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) { |       selectedAssets.value = selected ? selection : {}; | ||||||
|         if (forceAssetUpdate || |  | ||||||
|             mapEvent.source != MapEventSource.mapController) { |  | ||||||
|           debounce(() { |  | ||||||
|             if (selectionEnabledHook.value) { |  | ||||||
|               selectionEnabledHook.value = false; |  | ||||||
|             } |  | ||||||
|             reloadAssetsInBound( |  | ||||||
|               mapMarkerData.value, |  | ||||||
|               forceReload: forceAssetUpdate, |  | ||||||
|             ); |  | ||||||
|             forceAssetUpdate = false; |  | ||||||
|           }); |  | ||||||
|         } |  | ||||||
|       } else if (mapEvent is MapEventTap) { |  | ||||||
|         handleMapTapEvent(mapEvent.tapPosition); |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     void onShareAsset() { |     return MapThemeOveride( | ||||||
|       handleShareAssets(ref, context, selectedAssets.value.toList()); |       mapBuilder: (style) => context.isMobile | ||||||
|       selectionEnabledHook.value = false; |           // 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, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|     void onFavoriteAsset() async { | class _AssetMarkerMeta { | ||||||
|       showLoadingIndicator.value = true; |   final Point<num> point; | ||||||
|       try { |   final MapMarker marker; | ||||||
|         await handleFavoriteAssets(ref, context, selectedAssets.value.toList()); |   final bool shouldAnimate; | ||||||
|       } finally { |  | ||||||
|         showLoadingIndicator.value = false; |  | ||||||
|         selectionEnabledHook.value = false; |  | ||||||
|         refetchMarkers.value = true; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void onArchiveAsset() async { |   const _AssetMarkerMeta({ | ||||||
|       showLoadingIndicator.value = true; |     required this.point, | ||||||
|       try { |     required this.marker, | ||||||
|         await handleArchiveAssets(ref, context, selectedAssets.value.toList()); |     required this.shouldAnimate, | ||||||
|       } finally { |   }); | ||||||
|         showLoadingIndicator.value = false; |  | ||||||
|         selectionEnabledHook.value = false; |  | ||||||
|         refetchMarkers.value = true; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void selectionListener(bool isMultiSelect, Set<Asset> selection) { |   @override | ||||||
|       selectionEnabledHook.value = isMultiSelect; |   String toString() => | ||||||
|       selectedAssets.value = selection; |       '_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)'; | ||||||
|     } | } | ||||||
|  |  | ||||||
|     final markerLayer = MarkerLayer( | class _MapWithMarker extends StatelessWidget { | ||||||
|       markers: [ |   final AsyncValue<String> style; | ||||||
|         if (closestAssetMarker.value != null) |   final MapCreatedCallback onMapCreated; | ||||||
|           AssetMarker( |   final OnCameraIdleCallback onMapMoved; | ||||||
|             remoteId: closestAssetMarker.value!.asset.remoteId!, |   final OnMapClickCallback onMapClicked; | ||||||
|             anchorPos: AnchorPos.align(AnchorAlign.top), |   final OnStyleLoadedCallback onStyleLoaded; | ||||||
|             point: closestAssetMarker.value!.point, |   final Function()? onMarkerTapped; | ||||||
|             width: 100, |   final ValueNotifier<_AssetMarkerMeta?> selectedMarker; | ||||||
|             height: 100, |  | ||||||
|             builder: (ctx) => GestureDetector( |   const _MapWithMarker({ | ||||||
|               onTap: () => openAssetInViewer(closestAssetMarker.value!.asset), |     required this.style, | ||||||
|               child: AssetMarkerIcon( |     required this.onMapCreated, | ||||||
|                 key: Key(closestAssetMarker.value!.asset.remoteId!), |     required this.onMapMoved, | ||||||
|                 isDarkTheme: isDarkTheme, |     required this.onMapClicked, | ||||||
|                 id: closestAssetMarker.value!.asset.remoteId!, |     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( | ||||||
|     final heatMapLayer = mapMarkerData.value.isNotEmpty |                       point: value.point, | ||||||
|         ? HeatMapLayer( |                       assetRemoteId: value.marker.assetRemoteId, | ||||||
|             heatMapDataSource: InMemoryHeatMapDataSource( |                       durationInMilliseconds: value.shouldAnimate ? 100 : 0, | ||||||
|               data: mapMarkerData.value |                       onTap: onMarkerTapped, | ||||||
|                   .map( |                     ) | ||||||
|                     (e) => WeightedLatLng( |                   : const SizedBox.shrink(), | ||||||
|                       LatLng(e.point.latitude, e.point.longitude), |  | ||||||
|                       1, |  | ||||||
|                     ), |  | ||||||
|                   ) |  | ||||||
|                   .toList(), |  | ||||||
|             ), |             ), | ||||||
|             heatMapOptions: HeatMapOptions( |           ], | ||||||
|               radius: 60, |  | ||||||
|               layerOpacity: 0.5, |  | ||||||
|               gradient: { |  | ||||||
|                 0.20: Colors.deepPurple, |  | ||||||
|                 0.40: Colors.blue, |  | ||||||
|                 0.60: Colors.green, |  | ||||||
|                 0.95: Colors.yellow, |  | ||||||
|                 1.0: Colors.deepOrange, |  | ||||||
|               }, |  | ||||||
|             ), |  | ||||||
|           ) |  | ||||||
|         : const SizedBox.shrink(); |  | ||||||
|  |  | ||||||
|     return AnnotatedRegion<SystemUiOverlayStyle>( |  | ||||||
|       value: SystemUiOverlayStyle( |  | ||||||
|         statusBarColor: |  | ||||||
|             (isDarkTheme ? Colors.black : Colors.white).withOpacity(0.5), |  | ||||||
|         statusBarIconBrightness: |  | ||||||
|             isDarkTheme ? Brightness.light : Brightness.dark, |  | ||||||
|         systemNavigationBarColor: |  | ||||||
|             isDarkTheme ? Colors.grey[900] : Colors.grey[100], |  | ||||||
|         systemNavigationBarIconBrightness: |  | ||||||
|             isDarkTheme ? Brightness.light : Brightness.dark, |  | ||||||
|         systemNavigationBarDividerColor: Colors.transparent, |  | ||||||
|       ), |  | ||||||
|       child: Theme( |  | ||||||
|         // Override app theme based on map theme |  | ||||||
|         data: isDarkTheme ? immichDarkTheme : immichLightTheme, |  | ||||||
|         child: Scaffold( |  | ||||||
|           appBar: MapAppBar( |  | ||||||
|             isDarkTheme: isDarkTheme, |  | ||||||
|             selectionEnabled: selectionEnabledHook, |  | ||||||
|             selectedAssetsLength: selectedAssets.value.length, |  | ||||||
|             onShare: onShareAsset, |  | ||||||
|             onArchive: onArchiveAsset, |  | ||||||
|             onFavorite: onFavoriteAsset, |  | ||||||
|           ), |  | ||||||
|           extendBodyBehindAppBar: true, |  | ||||||
|           body: Stack( |  | ||||||
|             children: [ |  | ||||||
|               if (!isLoading) |  | ||||||
|                 FlutterMap( |  | ||||||
|                   mapController: mapController, |  | ||||||
|                   options: MapOptions( |  | ||||||
|                     maxBounds: |  | ||||||
|                         LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)), |  | ||||||
|                     interactiveFlags: InteractiveFlag.doubleTapZoom | |  | ||||||
|                         InteractiveFlag.drag | |  | ||||||
|                         InteractiveFlag.flingAnimation | |  | ||||||
|                         InteractiveFlag.pinchMove | |  | ||||||
|                         InteractiveFlag.pinchZoom, |  | ||||||
|                     center: LatLng(20, 20), |  | ||||||
|                     zoom: 2, |  | ||||||
|                     minZoom: 1, |  | ||||||
|                     maxZoom: maxZoom, |  | ||||||
|                     onMapReady: () { |  | ||||||
|                       isMapReady = true; |  | ||||||
|                       mapController.mapEventStream.listen(onMapEvent); |  | ||||||
|                     }, |  | ||||||
|                   ), |  | ||||||
|                   children: [ |  | ||||||
|                     ref.read(mapStateNotifier.notifier).getTileLayer(), |  | ||||||
|                     heatMapLayer, |  | ||||||
|                     markerLayer, |  | ||||||
|                   ], |  | ||||||
|                 ), |  | ||||||
|               if (!isLoading) |  | ||||||
|                 MapPageBottomSheet( |  | ||||||
|                   mapPageEventStream: mapPageEventSC.stream, |  | ||||||
|                   bottomSheetEventSC: bottomSheetEventSC, |  | ||||||
|                   selectionEnabled: selectionEnabledHook.value, |  | ||||||
|                   selectionlistener: selectionListener, |  | ||||||
|                   isDarkTheme: isDarkTheme, |  | ||||||
|                 ), |  | ||||||
|               if (showLoadingIndicator.value || isLoading) |  | ||||||
|                 Positioned( |  | ||||||
|                   top: context.height * 0.35, |  | ||||||
|                   left: context.width * 0.425, |  | ||||||
|                   child: const ImmichLoadingIndicator(), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|           ), |  | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class AssetMarker extends Marker { |  | ||||||
|   String remoteId; |  | ||||||
|  |  | ||||||
|   AssetMarker({ |  | ||||||
|     super.key, |  | ||||||
|     required this.remoteId, |  | ||||||
|     super.anchorPos, |  | ||||||
|     required super.point, |  | ||||||
|     super.width = 100.0, |  | ||||||
|     super.height = 100.0, |  | ||||||
|     required super.builder, |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
|   | |||||||
							
								
								
									
										159
									
								
								mobile/lib/modules/map/widgets/map_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								mobile/lib/modules/map/widgets/map_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | |||||||
|  | import 'dart:async'; | ||||||
|  |  | ||||||
|  | import 'package:auto_route/auto_route.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_settings_sheet.dart'; | ||||||
|  | import 'package:immich_mobile/shared/models/asset.dart'; | ||||||
|  | import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||||
|  | import 'package:immich_mobile/utils/selection_handlers.dart'; | ||||||
|  |  | ||||||
|  | class MapAppBar extends HookWidget implements PreferredSizeWidget { | ||||||
|  |   final ValueNotifier<Set<Asset>> selectedAssets; | ||||||
|  |  | ||||||
|  |   const MapAppBar({super.key, required this.selectedAssets}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Padding( | ||||||
|  |       padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25), | ||||||
|  |       child: ValueListenableBuilder( | ||||||
|  |         valueListenable: selectedAssets, | ||||||
|  |         builder: (ctx, value, child) => value.isNotEmpty | ||||||
|  |             ? _SelectionRow(selectedAssets: selectedAssets) | ||||||
|  |             : _NonSelectionRow(), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Size get preferredSize => const Size.fromHeight(100); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _NonSelectionRow extends StatelessWidget { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     void onSettingsPressed() { | ||||||
|  |       showModalBottomSheet( | ||||||
|  |         elevation: 0.0, | ||||||
|  |         showDragHandle: true, | ||||||
|  |         isScrollControlled: true, | ||||||
|  |         context: context, | ||||||
|  |         builder: (_) => const MapSettingsSheet(), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Row( | ||||||
|  |       mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |       children: [ | ||||||
|  |         ElevatedButton( | ||||||
|  |           onPressed: () => context.popRoute(), | ||||||
|  |           style: ElevatedButton.styleFrom( | ||||||
|  |             shape: const CircleBorder(), | ||||||
|  |           ), | ||||||
|  |           child: const Icon(Icons.arrow_back_ios_new_rounded), | ||||||
|  |         ), | ||||||
|  |         ElevatedButton( | ||||||
|  |           onPressed: onSettingsPressed, | ||||||
|  |           style: ElevatedButton.styleFrom( | ||||||
|  |             shape: const CircleBorder(), | ||||||
|  |           ), | ||||||
|  |           child: const Icon(Icons.more_vert_rounded), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _SelectionRow extends HookConsumerWidget { | ||||||
|  |   final ValueNotifier<Set<Asset>> selectedAssets; | ||||||
|  |  | ||||||
|  |   const _SelectionRow({required this.selectedAssets}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final isProcessing = useProcessingOverlay(); | ||||||
|  |  | ||||||
|  |     Future<void> handleProcessing( | ||||||
|  |       FutureOr<void> Function() action, [ | ||||||
|  |       bool reloadMarkers = false, | ||||||
|  |     ]) async { | ||||||
|  |       isProcessing.value = true; | ||||||
|  |       await action(); | ||||||
|  |       // Reset state | ||||||
|  |       selectedAssets.value = {}; | ||||||
|  |       isProcessing.value = false; | ||||||
|  |       if (reloadMarkers) { | ||||||
|  |         ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(true); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Row( | ||||||
|  |       children: [ | ||||||
|  |         Padding( | ||||||
|  |           padding: const EdgeInsets.only(left: 20), | ||||||
|  |           child: ElevatedButton.icon( | ||||||
|  |             onPressed: () => selectedAssets.value = {}, | ||||||
|  |             icon: const Icon(Icons.close_rounded), | ||||||
|  |             label: Text( | ||||||
|  |               '${selectedAssets.value.length}', | ||||||
|  |               style: context.textTheme.titleMedium?.copyWith( | ||||||
|  |                 color: context.colorScheme.onPrimary, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         Expanded( | ||||||
|  |           child: Row( | ||||||
|  |             mainAxisAlignment: MainAxisAlignment.end, | ||||||
|  |             children: [ | ||||||
|  |               ElevatedButton( | ||||||
|  |                 onPressed: () => handleProcessing( | ||||||
|  |                   () => handleShareAssets( | ||||||
|  |                     ref, | ||||||
|  |                     context, | ||||||
|  |                     selectedAssets.value.toList(), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 style: ElevatedButton.styleFrom( | ||||||
|  |                   shape: const CircleBorder(), | ||||||
|  |                 ), | ||||||
|  |                 child: const Icon(Icons.ios_share_rounded), | ||||||
|  |               ), | ||||||
|  |               ElevatedButton( | ||||||
|  |                 onPressed: () => handleProcessing( | ||||||
|  |                   () => handleFavoriteAssets( | ||||||
|  |                     ref, | ||||||
|  |                     context, | ||||||
|  |                     selectedAssets.value.toList(), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |                 style: ElevatedButton.styleFrom( | ||||||
|  |                   shape: const CircleBorder(), | ||||||
|  |                 ), | ||||||
|  |                 child: const Icon(Icons.favorite), | ||||||
|  |               ), | ||||||
|  |               ElevatedButton( | ||||||
|  |                 onPressed: () => handleProcessing( | ||||||
|  |                   () => handleArchiveAssets( | ||||||
|  |                     ref, | ||||||
|  |                     context, | ||||||
|  |                     selectedAssets.value.toList(), | ||||||
|  |                   ), | ||||||
|  |                   true, | ||||||
|  |                 ), | ||||||
|  |                 style: ElevatedButton.styleFrom( | ||||||
|  |                   shape: const CircleBorder(), | ||||||
|  |                 ), | ||||||
|  |                 child: const Icon(Icons.archive), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										273
									
								
								mobile/lib/modules/map/widgets/map_asset_grid.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										273
									
								
								mobile/lib/modules/map/widgets/map_asset_grid.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,273 @@ | |||||||
|  | import 'dart:math' as math; | ||||||
|  | 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:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/collection_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart'; | ||||||
|  | import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; | ||||||
|  | import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/models/map_event.model.dart'; | ||||||
|  | import 'package:immich_mobile/shared/models/asset.dart'; | ||||||
|  | import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||||
|  | import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | ||||||
|  | import 'package:immich_mobile/utils/color_filter_generator.dart'; | ||||||
|  | import 'package:immich_mobile/utils/throttle.dart'; | ||||||
|  | import 'package:logging/logging.dart'; | ||||||
|  | import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; | ||||||
|  |  | ||||||
|  | class MapAssetGrid extends HookConsumerWidget { | ||||||
|  |   final Stream<MapEvent> mapEventStream; | ||||||
|  |   final Function(String)? onGridAssetChanged; | ||||||
|  |   final Function(String)? onZoomToAsset; | ||||||
|  |   final Function(bool, Set<Asset>)? onAssetsSelected; | ||||||
|  |   final ValueNotifier<Set<Asset>> selectedAssets; | ||||||
|  |   final ScrollController controller; | ||||||
|  |  | ||||||
|  |   const MapAssetGrid({ | ||||||
|  |     required this.mapEventStream, | ||||||
|  |     this.onGridAssetChanged, | ||||||
|  |     this.onZoomToAsset, | ||||||
|  |     this.onAssetsSelected, | ||||||
|  |     required this.selectedAssets, | ||||||
|  |     required this.controller, | ||||||
|  |     super.key, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final log = Logger("MapAssetGrid"); | ||||||
|  |     final assetsInBounds = useState<List<Asset>>([]); | ||||||
|  |     final cachedRenderList = useRef<RenderList?>(null); | ||||||
|  |     final lastRenderElementIndex = useRef<int?>(null); | ||||||
|  |     final assetInSheet = useValueNotifier<String?>(null); | ||||||
|  |     final gridScrollThrottler = | ||||||
|  |         useThrottler(interval: const Duration(milliseconds: 300)); | ||||||
|  |  | ||||||
|  |     void handleMapEvents(MapEvent event) async { | ||||||
|  |       if (event is MapAssetsInBoundsUpdated) { | ||||||
|  |         assetsInBounds.value = await ref | ||||||
|  |             .read(dbProvider) | ||||||
|  |             .assets | ||||||
|  |             .getAllByRemoteId(event.assetRemoteIds); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents); | ||||||
|  |  | ||||||
|  |     // Hard-restrict to 4 assets / row in portrait mode | ||||||
|  |     const assetsPerRow = 4; | ||||||
|  |  | ||||||
|  |     void handleVisibleItems(Iterable<ItemPosition> positions) { | ||||||
|  |       final orderedPos = positions.sortedByField((p) => p.index); | ||||||
|  |       // Index of row where the items are mostly visible | ||||||
|  |       const partialOffset = 0.20; | ||||||
|  |       final item = orderedPos | ||||||
|  |           .firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset); | ||||||
|  |  | ||||||
|  |       // Guard no elements, reset state | ||||||
|  |       // Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0) | ||||||
|  |       if (item == null || item.itemLeadingEdge == 0) { | ||||||
|  |         lastRenderElementIndex.value = null; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       final renderElement = | ||||||
|  |           cachedRenderList.value?.elements.elementAtOrNull(item.index); | ||||||
|  |       // Guard no render list or render element | ||||||
|  |       if (renderElement == null) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       // Reset index | ||||||
|  |       lastRenderElementIndex.value == item.index; | ||||||
|  |  | ||||||
|  |       //  <RenderElement:offset:0> | ||||||
|  |       //  | 1 | 2 | 3 | 4 | 5 | 6 | | ||||||
|  |       //  <RenderElement:offset:6> | ||||||
|  |       //  | 7 | 8 | 9 | | ||||||
|  |       //  <RenderElement:offset:9> | ||||||
|  |       //  | 10 | | ||||||
|  |  | ||||||
|  |       // Skip through the assets from the previous row | ||||||
|  |       final rowOffset = renderElement.offset; | ||||||
|  |       // Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset | ||||||
|  |       final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge; | ||||||
|  |       final edgeOffset = (totalOffset - partialOffset) / | ||||||
|  |           // Round the total count to the next multiple of [assetsPerRow] | ||||||
|  |           ((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor(); | ||||||
|  |  | ||||||
|  |       // trailing should never be above the totalOffset | ||||||
|  |       final columnOffset = | ||||||
|  |           (totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/ | ||||||
|  |               edgeOffset; | ||||||
|  |       final assetOffset = rowOffset + columnOffset; | ||||||
|  |       final selectedAsset = cachedRenderList.value?.allAssets | ||||||
|  |           ?.elementAtOrNull(assetOffset) | ||||||
|  |           ?.remoteId; | ||||||
|  |  | ||||||
|  |       if (selectedAsset != null) { | ||||||
|  |         onGridAssetChanged?.call(selectedAsset); | ||||||
|  |         assetInSheet.value = selectedAsset; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Card( | ||||||
|  |       margin: EdgeInsets.zero, | ||||||
|  |       child: Stack( | ||||||
|  |         children: [ | ||||||
|  |           /// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the | ||||||
|  |           /// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves | ||||||
|  |           Align( | ||||||
|  |             alignment: Alignment.bottomCenter, | ||||||
|  |             child: FractionallySizedBox( | ||||||
|  |               // Place it just below the drag handle | ||||||
|  |               heightFactor: 0.80, | ||||||
|  |               child: assetsInBounds.value.isNotEmpty | ||||||
|  |                   ? ref.watch(renderListProvider(assetsInBounds.value)).when( | ||||||
|  |                         data: (renderList) { | ||||||
|  |                           // Cache render list here to use it back during visibleItemsListener | ||||||
|  |                           cachedRenderList.value = renderList; | ||||||
|  |                           return ValueListenableBuilder( | ||||||
|  |                             valueListenable: selectedAssets, | ||||||
|  |                             builder: (_, value, __) => ImmichAssetGrid( | ||||||
|  |                               shrinkWrap: true, | ||||||
|  |                               renderList: renderList, | ||||||
|  |                               showDragScroll: false, | ||||||
|  |                               assetsPerRow: assetsPerRow, | ||||||
|  |                               showMultiSelectIndicator: false, | ||||||
|  |                               selectionActive: value.isNotEmpty, | ||||||
|  |                               listener: onAssetsSelected, | ||||||
|  |                               visibleItemsListener: (pos) => gridScrollThrottler | ||||||
|  |                                   .run(() => handleVisibleItems(pos)), | ||||||
|  |                             ), | ||||||
|  |                           ); | ||||||
|  |                         }, | ||||||
|  |                         error: (error, stackTrace) { | ||||||
|  |                           log.warning( | ||||||
|  |                             "Cannot get assets in the current map bounds $error", | ||||||
|  |                             error, | ||||||
|  |                             stackTrace, | ||||||
|  |                           ); | ||||||
|  |                           return const SizedBox.shrink(); | ||||||
|  |                         }, | ||||||
|  |                         loading: () => const SizedBox.shrink(), | ||||||
|  |                       ) | ||||||
|  |                   : _MapNoAssetsInSheet(), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           _MapSheetDragRegion( | ||||||
|  |             controller: controller, | ||||||
|  |             assetsInBoundCount: assetsInBounds.value.length, | ||||||
|  |             assetInSheet: assetInSheet, | ||||||
|  |             onZoomToAsset: onZoomToAsset, | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _MapNoAssetsInSheet extends StatelessWidget { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     const image = Image( | ||||||
|  |       height: 150, | ||||||
|  |       width: 150, | ||||||
|  |       image: AssetImage('assets/lighthouse.png'), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return Center( | ||||||
|  |       child: ListView( | ||||||
|  |         shrinkWrap: true, | ||||||
|  |         children: [ | ||||||
|  |           context.isDarkTheme | ||||||
|  |               ? const InvertionFilter( | ||||||
|  |                   child: SaturationFilter( | ||||||
|  |                     saturation: -1, | ||||||
|  |                     child: BrightnessFilter( | ||||||
|  |                       brightness: -5, | ||||||
|  |                       child: image, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ) | ||||||
|  |               : image, | ||||||
|  |           const SizedBox(height: 20), | ||||||
|  |           Center( | ||||||
|  |             child: Text( | ||||||
|  |               "map_zoom_to_see_photos".tr(), | ||||||
|  |               style: context.textTheme.displayLarge?.copyWith(fontSize: 18), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _MapSheetDragRegion extends StatelessWidget { | ||||||
|  |   final ScrollController controller; | ||||||
|  |   final int assetsInBoundCount; | ||||||
|  |   final ValueNotifier<String?> assetInSheet; | ||||||
|  |   final Function(String)? onZoomToAsset; | ||||||
|  |  | ||||||
|  |   const _MapSheetDragRegion({ | ||||||
|  |     required this.controller, | ||||||
|  |     required this.assetsInBoundCount, | ||||||
|  |     required this.assetInSheet, | ||||||
|  |     this.onZoomToAsset, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final assetsInBoundsText = assetsInBoundCount > 0 | ||||||
|  |         ? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()]) | ||||||
|  |         : "map_no_assets_in_bounds".tr(); | ||||||
|  |  | ||||||
|  |     return SingleChildScrollView( | ||||||
|  |       controller: controller, | ||||||
|  |       physics: const ClampingScrollPhysics(), | ||||||
|  |       child: Card( | ||||||
|  |         margin: EdgeInsets.zero, | ||||||
|  |         shape: context.isMobile ? null : const BeveledRectangleBorder(), | ||||||
|  |         elevation: 0.0, | ||||||
|  |         child: Stack( | ||||||
|  |           children: [ | ||||||
|  |             Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |               mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |               children: [ | ||||||
|  |                 const SizedBox(height: 15), | ||||||
|  |                 const CustomDraggingHandle(), | ||||||
|  |                 const SizedBox(height: 15), | ||||||
|  |                 Text(assetsInBoundsText, style: context.textTheme.bodyLarge), | ||||||
|  |                 const Divider(height: 35), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |             ValueListenableBuilder( | ||||||
|  |               valueListenable: assetInSheet, | ||||||
|  |               builder: (_, value, __) => Visibility( | ||||||
|  |                 visible: value != null, | ||||||
|  |                 child: Positioned( | ||||||
|  |                   right: 15, | ||||||
|  |                   top: 15, | ||||||
|  |                   child: IconButton( | ||||||
|  |                     icon: Icon( | ||||||
|  |                       Icons.map_outlined, | ||||||
|  |                       color: context.textTheme.displayLarge?.color, | ||||||
|  |                     ), | ||||||
|  |                     iconSize: 20, | ||||||
|  |                     tooltip: 'Zoom to bounds', | ||||||
|  |                     onPressed: () => onZoomToAsset?.call(value!), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										97
									
								
								mobile/lib/modules/map/widgets/map_bottom_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								mobile/lib/modules/map/widgets/map_bottom_sheet.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/models/map_event.model.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart'; | ||||||
|  | import 'package:immich_mobile/shared/models/asset.dart'; | ||||||
|  | import 'package:immich_mobile/utils/draggable_scroll_controller.dart'; | ||||||
|  |  | ||||||
|  | class MapBottomSheet extends HookConsumerWidget { | ||||||
|  |   final Stream<MapEvent> mapEventStream; | ||||||
|  |   final Function(String)? onGridAssetChanged; | ||||||
|  |   final Function(String)? onZoomToAsset; | ||||||
|  |   final Function()? onZoomToLocation; | ||||||
|  |   final Function(bool, Set<Asset>)? onAssetsSelected; | ||||||
|  |   final ValueNotifier<Set<Asset>> selectedAssets; | ||||||
|  |  | ||||||
|  |   const MapBottomSheet({ | ||||||
|  |     required this.mapEventStream, | ||||||
|  |     this.onGridAssetChanged, | ||||||
|  |     this.onZoomToAsset, | ||||||
|  |     this.onAssetsSelected, | ||||||
|  |     this.onZoomToLocation, | ||||||
|  |     required this.selectedAssets, | ||||||
|  |     super.key, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     const sheetMinExtent = 0.1; | ||||||
|  |     final sheetController = useDraggableScrollController(); | ||||||
|  |     final bottomSheetOffset = useValueNotifier(sheetMinExtent); | ||||||
|  |     final isBottomSheetOpened = useRef(false); | ||||||
|  |  | ||||||
|  |     void handleMapEvents(MapEvent event) async { | ||||||
|  |       if (event is MapCloseBottomSheet) { | ||||||
|  |         sheetController.animateTo( | ||||||
|  |           0.1, | ||||||
|  |           duration: const Duration(milliseconds: 200), | ||||||
|  |           curve: Curves.linearToEaseOut, | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents); | ||||||
|  |  | ||||||
|  |     bool onScrollNotification(DraggableScrollableNotification notification) { | ||||||
|  |       isBottomSheetOpened.value = | ||||||
|  |           notification.extent > (notification.maxExtent * 0.9); | ||||||
|  |       bottomSheetOffset.value = notification.extent; | ||||||
|  |       // do not bubble | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Stack( | ||||||
|  |       children: [ | ||||||
|  |         NotificationListener<DraggableScrollableNotification>( | ||||||
|  |           onNotification: onScrollNotification, | ||||||
|  |           child: DraggableScrollableSheet( | ||||||
|  |             controller: sheetController, | ||||||
|  |             minChildSize: sheetMinExtent, | ||||||
|  |             maxChildSize: 0.5, | ||||||
|  |             initialChildSize: sheetMinExtent, | ||||||
|  |             snap: true, | ||||||
|  |             shouldCloseOnMinExtent: false, | ||||||
|  |             builder: (ctx, scrollController) => MapAssetGrid( | ||||||
|  |               controller: scrollController, | ||||||
|  |               mapEventStream: mapEventStream, | ||||||
|  |               selectedAssets: selectedAssets, | ||||||
|  |               onAssetsSelected: onAssetsSelected, | ||||||
|  |               // Do not bother with the event if the bottom sheet is not user scrolled | ||||||
|  |               onGridAssetChanged: (assetId) => isBottomSheetOpened.value | ||||||
|  |                   ? onGridAssetChanged?.call(assetId) | ||||||
|  |                   : null, | ||||||
|  |               onZoomToAsset: onZoomToAsset, | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         ValueListenableBuilder( | ||||||
|  |           valueListenable: bottomSheetOffset, | ||||||
|  |           builder: (ctx, value, child) => Positioned( | ||||||
|  |             right: 0, | ||||||
|  |             bottom: context.height * (value + 0.02), | ||||||
|  |             child: child!, | ||||||
|  |           ), | ||||||
|  |           child: ElevatedButton( | ||||||
|  |             onPressed: onZoomToLocation, | ||||||
|  |             style: ElevatedButton.styleFrom( | ||||||
|  |               shape: const CircleBorder(), | ||||||
|  |             ), | ||||||
|  |             child: const Icon(Icons.my_location), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,31 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  |  | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
|  |  | ||||||
|  | class MapSettingsListTile extends StatelessWidget { | ||||||
|  |   final String title; | ||||||
|  |   final bool selected; | ||||||
|  |   final Function(bool) onChanged; | ||||||
|  |  | ||||||
|  |   const MapSettingsListTile({ | ||||||
|  |     super.key, | ||||||
|  |     required this.title, | ||||||
|  |     required this.selected, | ||||||
|  |     required this.onChanged, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return SwitchListTile.adaptive( | ||||||
|  |       activeColor: context.primaryColor, | ||||||
|  |       title: Text( | ||||||
|  |         title, | ||||||
|  |         style: | ||||||
|  |             context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), | ||||||
|  |       ).tr(), | ||||||
|  |       value: selected, | ||||||
|  |       onChanged: onChanged, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,92 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  |  | ||||||
|  | class MapTimeDropDown extends StatelessWidget { | ||||||
|  |   final int relativeTime; | ||||||
|  |   final Function(int) onTimeChange; | ||||||
|  |  | ||||||
|  |   const MapTimeDropDown({ | ||||||
|  |     super.key, | ||||||
|  |     required this.relativeTime, | ||||||
|  |     required this.onTimeChange, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final now = DateTime.now(); | ||||||
|  |  | ||||||
|  |     return Column( | ||||||
|  |       crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |       children: [ | ||||||
|  |         Padding( | ||||||
|  |           padding: const EdgeInsets.only(bottom: 20), | ||||||
|  |           child: Text( | ||||||
|  |             "map_settings_only_relative_range".tr(), | ||||||
|  |             style: const TextStyle(fontWeight: FontWeight.bold), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         LayoutBuilder( | ||||||
|  |           builder: (_, constraints) => DropdownMenu( | ||||||
|  |             width: constraints.maxWidth * 0.9, | ||||||
|  |             enableSearch: false, | ||||||
|  |             enableFilter: false, | ||||||
|  |             initialSelection: relativeTime, | ||||||
|  |             onSelected: (value) => onTimeChange(value!), | ||||||
|  |             dropdownMenuEntries: [ | ||||||
|  |               DropdownMenuEntry( | ||||||
|  |                 value: 0, | ||||||
|  |                 label: "map_settings_date_range_option_all".tr(), | ||||||
|  |               ), | ||||||
|  |               DropdownMenuEntry( | ||||||
|  |                 value: 1, | ||||||
|  |                 label: "map_settings_date_range_option_day".tr(), | ||||||
|  |               ), | ||||||
|  |               DropdownMenuEntry( | ||||||
|  |                 value: 7, | ||||||
|  |                 label: "map_settings_date_range_option_days".tr( | ||||||
|  |                   args: ["7"], | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               DropdownMenuEntry( | ||||||
|  |                 value: 30, | ||||||
|  |                 label: "map_settings_date_range_option_days".tr( | ||||||
|  |                   args: ["30"], | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               DropdownMenuEntry( | ||||||
|  |                 value: now | ||||||
|  |                     .difference( | ||||||
|  |                       DateTime( | ||||||
|  |                         now.year - 1, | ||||||
|  |                         now.month, | ||||||
|  |                         now.day, | ||||||
|  |                         now.hour, | ||||||
|  |                         now.minute, | ||||||
|  |                         now.second, | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                     .inDays, | ||||||
|  |                 label: "map_settings_date_range_option_year".tr(), | ||||||
|  |               ), | ||||||
|  |               DropdownMenuEntry( | ||||||
|  |                 value: now | ||||||
|  |                     .difference( | ||||||
|  |                       DateTime( | ||||||
|  |                         now.year - 3, | ||||||
|  |                         now.month, | ||||||
|  |                         now.day, | ||||||
|  |                         now.hour, | ||||||
|  |                         now.minute, | ||||||
|  |                         now.second, | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                     .inDays, | ||||||
|  |                 label: "map_settings_date_range_option_years".tr(args: ["3"]), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,109 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
|  | class MapThemePicker extends StatelessWidget { | ||||||
|  |   final ThemeMode themeMode; | ||||||
|  |   final Function(ThemeMode) onThemeChange; | ||||||
|  |  | ||||||
|  |   const MapThemePicker({ | ||||||
|  |     super.key, | ||||||
|  |     required this.themeMode, | ||||||
|  |     required this.onThemeChange, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Column( | ||||||
|  |       children: [ | ||||||
|  |         Padding( | ||||||
|  |           padding: const EdgeInsets.only(bottom: 20), | ||||||
|  |           child: Center( | ||||||
|  |             child: Text( | ||||||
|  |               "map_settings_theme_settings", | ||||||
|  |               style: context.textTheme.bodyMedium | ||||||
|  |                   ?.copyWith(fontWeight: FontWeight.bold), | ||||||
|  |             ).tr(), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         Row( | ||||||
|  |           mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |           children: [ | ||||||
|  |             _BorderedMapThumbnail( | ||||||
|  |               name: "Light", | ||||||
|  |               mode: ThemeMode.light, | ||||||
|  |               shouldHighlight: themeMode == ThemeMode.light, | ||||||
|  |               onThemeChange: onThemeChange, | ||||||
|  |             ), | ||||||
|  |             _BorderedMapThumbnail( | ||||||
|  |               name: "Dark", | ||||||
|  |               mode: ThemeMode.dark, | ||||||
|  |               shouldHighlight: themeMode == ThemeMode.dark, | ||||||
|  |               onThemeChange: onThemeChange, | ||||||
|  |             ), | ||||||
|  |             _BorderedMapThumbnail( | ||||||
|  |               name: "System", | ||||||
|  |               mode: ThemeMode.system, | ||||||
|  |               shouldHighlight: themeMode == ThemeMode.system, | ||||||
|  |               onThemeChange: onThemeChange, | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _BorderedMapThumbnail extends StatelessWidget { | ||||||
|  |   final ThemeMode mode; | ||||||
|  |   final String name; | ||||||
|  |   final bool shouldHighlight; | ||||||
|  |   final Function(ThemeMode) onThemeChange; | ||||||
|  |  | ||||||
|  |   const _BorderedMapThumbnail({ | ||||||
|  |     required this.mode, | ||||||
|  |     required this.name, | ||||||
|  |     required this.shouldHighlight, | ||||||
|  |     required this.onThemeChange, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Column( | ||||||
|  |       children: [ | ||||||
|  |         Container( | ||||||
|  |           decoration: BoxDecoration( | ||||||
|  |             border: Border.fromBorderSide( | ||||||
|  |               BorderSide( | ||||||
|  |                 width: 4, | ||||||
|  |                 color: shouldHighlight | ||||||
|  |                     ? context.colorScheme.onSurface | ||||||
|  |                     : Colors.transparent, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             borderRadius: const BorderRadius.all(Radius.circular(20)), | ||||||
|  |           ), | ||||||
|  |           child: MapThumbnail( | ||||||
|  |             zoom: 2, | ||||||
|  |             centre: const LatLng(47, 5), | ||||||
|  |             onTap: (_, __) => onThemeChange(mode), | ||||||
|  |             themeMode: mode, | ||||||
|  |             showAttribution: false, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         Padding( | ||||||
|  |           padding: const EdgeInsets.only(top: 10), | ||||||
|  |           child: Text( | ||||||
|  |             name, | ||||||
|  |             style: context.textTheme.bodyMedium?.copyWith( | ||||||
|  |               fontWeight: shouldHighlight ? FontWeight.bold : null, | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										61
									
								
								mobile/lib/modules/map/widgets/map_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								mobile/lib/modules/map/widgets/map_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_settings/map_settings_list_tile.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_settings/map_settings_time_dropdown.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_settings/map_theme_picker.dart'; | ||||||
|  |  | ||||||
|  | class MapSettingsSheet extends HookConsumerWidget { | ||||||
|  |   const MapSettingsSheet({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final mapState = ref.watch(mapStateNotifierProvider); | ||||||
|  |  | ||||||
|  |     return DraggableScrollableSheet( | ||||||
|  |       expand: false, | ||||||
|  |       initialChildSize: 0.6, | ||||||
|  |       builder: (ctx, scrollController) => SingleChildScrollView( | ||||||
|  |         controller: scrollController, | ||||||
|  |         child: Card( | ||||||
|  |           elevation: 0.0, | ||||||
|  |           shadowColor: Colors.transparent, | ||||||
|  |           margin: EdgeInsets.zero, | ||||||
|  |           child: Column( | ||||||
|  |             mainAxisSize: MainAxisSize.max, | ||||||
|  |             children: [ | ||||||
|  |               MapThemePicker( | ||||||
|  |                 themeMode: mapState.themeMode, | ||||||
|  |                 onThemeChange: (mode) => ref | ||||||
|  |                     .read(mapStateNotifierProvider.notifier) | ||||||
|  |                     .switchTheme(mode), | ||||||
|  |               ), | ||||||
|  |               const Divider(height: 30, thickness: 2), | ||||||
|  |               MapSettingsListTile( | ||||||
|  |                 title: "map_settings_only_show_favorites", | ||||||
|  |                 selected: mapState.showFavoriteOnly, | ||||||
|  |                 onChanged: (favoriteOnly) => ref | ||||||
|  |                     .read(mapStateNotifierProvider.notifier) | ||||||
|  |                     .switchFavoriteOnly(favoriteOnly), | ||||||
|  |               ), | ||||||
|  |               MapSettingsListTile( | ||||||
|  |                 title: "map_settings_include_show_archived", | ||||||
|  |                 selected: mapState.includeArchived, | ||||||
|  |                 onChanged: (includeArchive) => ref | ||||||
|  |                     .read(mapStateNotifierProvider.notifier) | ||||||
|  |                     .switchIncludeArchived(includeArchive), | ||||||
|  |               ), | ||||||
|  |               MapTimeDropDown( | ||||||
|  |                 relativeTime: mapState.relativeTime, | ||||||
|  |                 onTimeChange: (time) => ref | ||||||
|  |                     .read(mapStateNotifierProvider.notifier) | ||||||
|  |                     .setRelativeTime(time), | ||||||
|  |               ), | ||||||
|  |               const SizedBox(height: 20), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										96
									
								
								mobile/lib/modules/map/widgets/map_theme_override.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								mobile/lib/modules/map/widgets/map_theme_override.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
|  | import 'package:immich_mobile/utils/immich_app_theme.dart'; | ||||||
|  |  | ||||||
|  | /// Overrides the theme below the widget tree to use the theme data based on the | ||||||
|  | /// map settings instead of the one from the app settings | ||||||
|  | class MapThemeOveride extends StatefulHookConsumerWidget { | ||||||
|  |   final ThemeMode? themeMode; | ||||||
|  |   final Widget Function(AsyncValue<String> style) mapBuilder; | ||||||
|  |  | ||||||
|  |   const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   ConsumerState createState() => _MapThemeOverideState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _MapThemeOverideState extends ConsumerState<MapThemeOveride> | ||||||
|  |     with WidgetsBindingObserver { | ||||||
|  |   late ThemeMode _theme; | ||||||
|  |   bool _isDarkTheme = false; | ||||||
|  |  | ||||||
|  |   bool get _isSystemDark => | ||||||
|  |       WidgetsBinding.instance.platformDispatcher.platformBrightness == | ||||||
|  |       Brightness.dark; | ||||||
|  |  | ||||||
|  |   bool checkDarkTheme() { | ||||||
|  |     return _theme == ThemeMode.dark || | ||||||
|  |         _theme == ThemeMode.system && _isSystemDark; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _theme = widget.themeMode ?? | ||||||
|  |         ref.read(mapStateNotifierProvider.select((v) => v.themeMode)); | ||||||
|  |     setState(() { | ||||||
|  |       _isDarkTheme = checkDarkTheme(); | ||||||
|  |     }); | ||||||
|  |     if (_theme == ThemeMode.system) { | ||||||
|  |       WidgetsBinding.instance.addObserver(this); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void didChangeDependencies() { | ||||||
|  |     super.didChangeDependencies(); | ||||||
|  |     if (_theme != ThemeMode.system) { | ||||||
|  |       WidgetsBinding.instance.removeObserver(this); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     WidgetsBinding.instance.removeObserver(this); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void didChangePlatformBrightness() { | ||||||
|  |     super.didChangePlatformBrightness(); | ||||||
|  |  | ||||||
|  |     if (_theme == ThemeMode.system) { | ||||||
|  |       setState(() => _isDarkTheme = _isSystemDark); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     _theme = widget.themeMode ?? | ||||||
|  |         ref.watch(mapStateNotifierProvider.select((v) => v.themeMode)); | ||||||
|  |  | ||||||
|  |     useValueChanged<ThemeMode, void>(_theme, (_, __) { | ||||||
|  |       if (_theme == ThemeMode.system) { | ||||||
|  |         WidgetsBinding.instance.addObserver(this); | ||||||
|  |       } else { | ||||||
|  |         WidgetsBinding.instance.removeObserver(this); | ||||||
|  |       } | ||||||
|  |       setState(() { | ||||||
|  |         _isDarkTheme = checkDarkTheme(); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return Theme( | ||||||
|  |       data: _isDarkTheme ? immichDarkTheme : immichLightTheme, | ||||||
|  |       child: widget.mapBuilder.call( | ||||||
|  |         ref.watch( | ||||||
|  |           mapStateNotifierProvider.select( | ||||||
|  |             (v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										110
									
								
								mobile/lib/modules/map/widgets/map_thumbnail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								mobile/lib/modules/map/widgets/map_thumbnail.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | |||||||
|  | import 'dart:math'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/maplibrecontroller_extensions.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:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
|  | /// A non-interactive thumbnail of a map in the given coordinates with optional markers | ||||||
|  | /// | ||||||
|  | /// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set | ||||||
|  | /// [showMarkerPin] to true which would display a marker pin instead. If both are provided, | ||||||
|  | /// [assetMarkerRemoteId] will take precedence | ||||||
|  | class MapThumbnail extends HookConsumerWidget { | ||||||
|  |   final Function(Point<double>, LatLng)? onTap; | ||||||
|  |   final LatLng centre; | ||||||
|  |   final String? assetMarkerRemoteId; | ||||||
|  |   final bool showMarkerPin; | ||||||
|  |   final double zoom; | ||||||
|  |   final double height; | ||||||
|  |   final double width; | ||||||
|  |   final ThemeMode? themeMode; | ||||||
|  |   final bool showAttribution; | ||||||
|  |  | ||||||
|  |   const MapThumbnail({ | ||||||
|  |     super.key, | ||||||
|  |     required this.centre, | ||||||
|  |     this.height = 100, | ||||||
|  |     this.width = 100, | ||||||
|  |     this.onTap, | ||||||
|  |     this.zoom = 8, | ||||||
|  |     this.assetMarkerRemoteId, | ||||||
|  |     this.showMarkerPin = false, | ||||||
|  |     this.themeMode, | ||||||
|  |     this.showAttribution = true, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); | ||||||
|  |     final controller = useRef<MaplibreMapController?>(null); | ||||||
|  |     final position = useValueNotifier<Point<num>?>(null); | ||||||
|  |  | ||||||
|  |     Future<void> onMapCreated(MaplibreMapController mapController) async { | ||||||
|  |       controller.value = mapController; | ||||||
|  |       if (assetMarkerRemoteId != null) { | ||||||
|  |         // The iOS impl returns wrong toScreenLocation without the delay | ||||||
|  |         Future.delayed( | ||||||
|  |           const Duration(milliseconds: 100), | ||||||
|  |           () async => | ||||||
|  |               position.value = await mapController.toScreenLocation(centre), | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Future<void> onStyleLoaded() async { | ||||||
|  |       if (showMarkerPin && controller.value != null) { | ||||||
|  |         await controller.value?.addMarkerAtLatLng(centre); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return MapThemeOveride( | ||||||
|  |       themeMode: themeMode, | ||||||
|  |       mapBuilder: (style) => SizedBox( | ||||||
|  |         height: height, | ||||||
|  |         width: width, | ||||||
|  |         child: ClipRRect( | ||||||
|  |           borderRadius: const BorderRadius.all(Radius.circular(15)), | ||||||
|  |           child: Stack( | ||||||
|  |             alignment: Alignment.center, | ||||||
|  |             children: [ | ||||||
|  |               style.widgetWhen( | ||||||
|  |                 onData: (style) => MaplibreMap( | ||||||
|  |                   initialCameraPosition: | ||||||
|  |                       CameraPosition(target: offsettedCentre, zoom: zoom), | ||||||
|  |                   styleString: style, | ||||||
|  |                   onMapCreated: onMapCreated, | ||||||
|  |                   onStyleLoadedCallback: onStyleLoaded, | ||||||
|  |                   onMapClick: onTap, | ||||||
|  |                   doubleClickZoomEnabled: false, | ||||||
|  |                   dragEnabled: false, | ||||||
|  |                   zoomGesturesEnabled: false, | ||||||
|  |                   tiltGesturesEnabled: false, | ||||||
|  |                   scrollGesturesEnabled: false, | ||||||
|  |                   rotateGesturesEnabled: false, | ||||||
|  |                   myLocationEnabled: false, | ||||||
|  |                   attributionButtonMargins: | ||||||
|  |                       showAttribution == false ? const Point(-100, 0) : null, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               ValueListenableBuilder( | ||||||
|  |                 valueListenable: position, | ||||||
|  |                 builder: (_, value, __) => value != null | ||||||
|  |                     ? PositionedAssetMarkerIcon( | ||||||
|  |                         size: height / 2, | ||||||
|  |                         point: value, | ||||||
|  |                         assetRemoteId: assetMarkerRemoteId!, | ||||||
|  |                       ) | ||||||
|  |                     : const SizedBox.shrink(), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,17 +1,57 @@ | |||||||
|  | import 'dart:io'; | ||||||
|  | import 'dart:math'; | ||||||
|  | 
 | ||||||
| import 'package:cached_network_image/cached_network_image.dart'; | import 'package:cached_network_image/cached_network_image.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
| import 'package:immich_mobile/shared/models/store.dart'; | import 'package:immich_mobile/shared/models/store.dart'; | ||||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||||
| 
 | 
 | ||||||
| class AssetMarkerIcon extends StatelessWidget { | class PositionedAssetMarkerIcon extends StatelessWidget { | ||||||
|   const AssetMarkerIcon({ |   final Point<num> point; | ||||||
|  |   final String assetRemoteId; | ||||||
|  |   final double size; | ||||||
|  |   final int durationInMilliseconds; | ||||||
|  | 
 | ||||||
|  |   final Function()? onTap; | ||||||
|  | 
 | ||||||
|  |   const PositionedAssetMarkerIcon({ | ||||||
|  |     required this.point, | ||||||
|  |     required this.assetRemoteId, | ||||||
|  |     this.size = 100, | ||||||
|  |     this.durationInMilliseconds = 100, | ||||||
|  |     this.onTap, | ||||||
|     super.key, |     super.key, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final ratio = Platform.isIOS ? 1.0 : MediaQuery.devicePixelRatioOf(context); | ||||||
|  |     return AnimatedPositioned( | ||||||
|  |       left: point.x / ratio - size / 2, | ||||||
|  |       top: point.y / ratio - size, | ||||||
|  |       duration: Duration(milliseconds: durationInMilliseconds), | ||||||
|  |       child: GestureDetector( | ||||||
|  |         onTap: () => onTap?.call(), | ||||||
|  |         child: SizedBox.square( | ||||||
|  |           dimension: size, | ||||||
|  |           child: _AssetMarkerIcon( | ||||||
|  |             id: assetRemoteId, | ||||||
|  |             key: Key(assetRemoteId), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class _AssetMarkerIcon extends StatelessWidget { | ||||||
|  |   const _AssetMarkerIcon({ | ||||||
|     required this.id, |     required this.id, | ||||||
|     this.isDarkTheme = false, |     super.key, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   final String id; |   final String id; | ||||||
|   final bool isDarkTheme; |  | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -26,8 +66,8 @@ class AssetMarkerIcon extends StatelessWidget { | |||||||
|               left: constraints.maxWidth * 0.5, |               left: constraints.maxWidth * 0.5, | ||||||
|               child: CustomPaint( |               child: CustomPaint( | ||||||
|                 painter: _PinPainter( |                 painter: _PinPainter( | ||||||
|                   primaryColor: isDarkTheme ? Colors.white : Colors.black, |                   primaryColor: context.colorScheme.onSurface, | ||||||
|                   secondaryColor: isDarkTheme ? Colors.black : Colors.white, |                   secondaryColor: context.colorScheme.surface, | ||||||
|                   primaryRadius: constraints.maxHeight * 0.06, |                   primaryRadius: constraints.maxHeight * 0.06, | ||||||
|                   secondaryRadius: constraints.maxHeight * 0.038, |                   secondaryRadius: constraints.maxHeight * 0.038, | ||||||
|                 ), |                 ), | ||||||
| @@ -42,7 +82,7 @@ class AssetMarkerIcon extends StatelessWidget { | |||||||
|               left: constraints.maxWidth * 0.17, |               left: constraints.maxWidth * 0.17, | ||||||
|               child: CircleAvatar( |               child: CircleAvatar( | ||||||
|                 radius: constraints.maxHeight * 0.40, |                 radius: constraints.maxHeight * 0.40, | ||||||
|                 backgroundColor: isDarkTheme ? Colors.white : Colors.black, |                 backgroundColor: context.colorScheme.onSurface, | ||||||
|                 child: CircleAvatar( |                 child: CircleAvatar( | ||||||
|                   radius: constraints.maxHeight * 0.37, |                   radius: constraints.maxHeight * 0.37, | ||||||
|                   backgroundImage: CachedNetworkImageProvider( |                   backgroundImage: CachedNetworkImageProvider( | ||||||
| @@ -72,8 +112,8 @@ class _PinPainter extends CustomPainter { | |||||||
|   final double secondaryRadius; |   final double secondaryRadius; | ||||||
| 
 | 
 | ||||||
|   _PinPainter({ |   _PinPainter({ | ||||||
|     this.primaryColor = Colors.black, |     required this.primaryColor, | ||||||
|     this.secondaryColor = Colors.white, |     required this.secondaryColor, | ||||||
|     required this.primaryRadius, |     required this.primaryRadius, | ||||||
|     required this.secondaryRadius, |     required this.secondaryRadius, | ||||||
|   }); |   }); | ||||||
| @@ -6,7 +6,7 @@ part of 'person.service.dart'; | |||||||
| // RiverpodGenerator | // RiverpodGenerator | ||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
| 
 | 
 | ||||||
| String _$personServiceHash() => r'cde0a9c029d16ddde2adcd58ae8c863bf8cc1fed'; | String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798'; | ||||||
| 
 | 
 | ||||||
| /// See also [personService]. | /// See also [personService]. | ||||||
| @ProviderFor(personService) | @ProviderFor(personService) | ||||||
|   | |||||||
| @@ -1,13 +1,12 @@ | |||||||
| import 'package:auto_route/auto_route.dart'; | import 'package:auto_route/auto_route.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; | import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; |  | ||||||
| import 'package:immich_mobile/modules/search/ui/curated_row.dart'; | import 'package:immich_mobile/modules/search/ui/curated_row.dart'; | ||||||
| import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; | import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; | ||||||
| import 'package:immich_mobile/routing/router.dart'; | import 'package:immich_mobile/routing/router.dart'; | ||||||
| import 'package:immich_mobile/shared/models/store.dart'; | import 'package:immich_mobile/shared/models/store.dart'; | ||||||
| import 'package:latlong2/latlong.dart'; | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
| class CuratedPlacesRow extends CuratedRow { | class CuratedPlacesRow extends CuratedRow { | ||||||
|   final bool isMapEnabled; |   final bool isMapEnabled; | ||||||
| @@ -38,14 +37,13 @@ class CuratedPlacesRow extends CuratedRow { | |||||||
|                 padding: const EdgeInsets.only(right: 10.0), |                 padding: const EdgeInsets.only(right: 10.0), | ||||||
|                 child: MapThumbnail( |                 child: MapThumbnail( | ||||||
|                   zoom: 2, |                   zoom: 2, | ||||||
|                   coords: LatLng( |                   centre: const LatLng( | ||||||
|                     47, |                     47, | ||||||
|                     5, |                     5, | ||||||
|                   ), |                   ), | ||||||
|                   height: imageSize, |                   height: imageSize, | ||||||
|                   width: imageSize, |                   width: imageSize, | ||||||
|                   showAttribution: false, |                   showAttribution: false, | ||||||
|                   isDarkTheme: context.isDarkTheme, |  | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|               Padding( |               Padding( | ||||||
|   | |||||||
| @@ -46,7 +46,7 @@ enum AppSettingsEnum<T> { | |||||||
|   advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), |   advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), | ||||||
|   logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 |   logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 | ||||||
|   preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), |   preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), | ||||||
|   mapThemeMode<bool>(StoreKey.mapThemeMode, null, false), |   mapThemeMode<int>(StoreKey.mapThemeMode, null, 0), | ||||||
|   mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false), |   mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false), | ||||||
|   mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false), |   mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false), | ||||||
|   mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0), |   mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0), | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ import 'package:immich_mobile/modules/album/views/asset_selection_page.dart'; | |||||||
| import 'package:immich_mobile/modules/album/views/create_album_page.dart'; | import 'package:immich_mobile/modules/album/views/create_album_page.dart'; | ||||||
| import 'package:immich_mobile/modules/album/views/library_page.dart'; | import 'package:immich_mobile/modules/album/views/library_page.dart'; | ||||||
| import 'package:immich_mobile/modules/backup/views/backup_options_page.dart'; | import 'package:immich_mobile/modules/backup/views/backup_options_page.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/map_location_picker.dart'; | import 'package:immich_mobile/modules/map/views/map_location_picker_page.dart'; | ||||||
| import 'package:immich_mobile/modules/map/views/map_page.dart'; | import 'package:immich_mobile/modules/map/views/map_page.dart'; | ||||||
| import 'package:immich_mobile/modules/memories/models/memory.dart'; | import 'package:immich_mobile/modules/memories/models/memory.dart'; | ||||||
| import 'package:immich_mobile/modules/memories/views/memory_page.dart'; | import 'package:immich_mobile/modules/memories/views/memory_page.dart'; | ||||||
| @@ -59,8 +59,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart'; | |||||||
| import 'package:immich_mobile/shared/views/splash_screen.dart'; | import 'package:immich_mobile/shared/views/splash_screen.dart'; | ||||||
| import 'package:immich_mobile/shared/views/tab_controller_page.dart'; | import 'package:immich_mobile/shared/views/tab_controller_page.dart'; | ||||||
| import 'package:isar/isar.dart'; | import 'package:isar/isar.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
| import 'package:photo_manager/photo_manager.dart' hide LatLng; | import 'package:photo_manager/photo_manager.dart' hide LatLng; | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
|  |  | ||||||
| part 'router.gr.dart'; | part 'router.gr.dart'; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1593,7 +1593,7 @@ class ActivitiesRoute extends PageRouteInfo<void> { | |||||||
| class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { | class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { | ||||||
|   MapLocationPickerRoute({ |   MapLocationPickerRoute({ | ||||||
|     Key? key, |     Key? key, | ||||||
|     LatLng? initialLatLng, |     LatLng initialLatLng = const LatLng(0, 0), | ||||||
|   }) : super( |   }) : super( | ||||||
|           MapLocationPickerRoute.name, |           MapLocationPickerRoute.name, | ||||||
|           path: '/map-location-picker-page', |           path: '/map-location-picker-page', | ||||||
| @@ -1609,12 +1609,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { | |||||||
| class MapLocationPickerRouteArgs { | class MapLocationPickerRouteArgs { | ||||||
|   const MapLocationPickerRouteArgs({ |   const MapLocationPickerRouteArgs({ | ||||||
|     this.key, |     this.key, | ||||||
|     this.initialLatLng, |     this.initialLatLng = const LatLng(0, 0), | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   final Key? key; |   final Key? key; | ||||||
|  |  | ||||||
|   final LatLng? initialLatLng; |   final LatLng initialLatLng; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String toString() { |   String toString() { | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import 'package:collection/collection.dart'; | import 'package:collection/collection.dart'; | ||||||
| import 'package:immich_mobile/shared/models/user.dart'; | import 'package:immich_mobile/shared/models/user.dart'; | ||||||
| import 'package:isar/isar.dart'; | import 'package:isar/isar.dart'; | ||||||
|  | import 'package:logging/logging.dart'; | ||||||
|  |  | ||||||
| part 'store.g.dart'; | part 'store.g.dart'; | ||||||
|  |  | ||||||
| @@ -8,6 +9,7 @@ part 'store.g.dart'; | |||||||
| /// Supports String, int and JSON-serializable Objects | /// Supports String, int and JSON-serializable Objects | ||||||
| /// Can be used concurrently from multiple isolates | /// Can be used concurrently from multiple isolates | ||||||
| class Store { | class Store { | ||||||
|  |   static final Logger _log = Logger("Store"); | ||||||
|   static late final Isar _db; |   static late final Isar _db; | ||||||
|   static final List<dynamic> _cache = |   static final List<dynamic> _cache = | ||||||
|       List.filled(StoreKey.values.map((e) => e.id).max + 1, null); |       List.filled(StoreKey.values.map((e) => e.id).max + 1, null); | ||||||
| @@ -72,8 +74,12 @@ class Store { | |||||||
|   static void _onChangeListener(List<StoreValue>? data) { |   static void _onChangeListener(List<StoreValue>? data) { | ||||||
|     if (data != null) { |     if (data != null) { | ||||||
|       for (StoreValue value in data) { |       for (StoreValue value in data) { | ||||||
|         _cache[value.id] = |         final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id); | ||||||
|             value._extract(StoreKey.values.firstWhere((e) => e.id == value.id)); |         if (key != null) { | ||||||
|  |           _cache[value.id] = value._extract(key); | ||||||
|  |         } else { | ||||||
|  |           _log.warning("No key available for value id - ${value.id}"); | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -177,13 +183,13 @@ enum StoreKey<T> { | |||||||
|   logLevel<int>(115, type: int), |   logLevel<int>(115, type: int), | ||||||
|   preferRemoteImage<bool>(116, type: bool), |   preferRemoteImage<bool>(116, type: bool), | ||||||
|   // map related settings |   // map related settings | ||||||
|   mapThemeMode<bool>(117, type: bool), |  | ||||||
|   mapShowFavoriteOnly<bool>(118, type: bool), |   mapShowFavoriteOnly<bool>(118, type: bool), | ||||||
|   mapRelativeDate<int>(119, type: int), |   mapRelativeDate<int>(119, type: int), | ||||||
|   selfSignedCert<bool>(120, type: bool), |   selfSignedCert<bool>(120, type: bool), | ||||||
|   mapIncludeArchived<bool>(121, type: bool), |   mapIncludeArchived<bool>(121, type: bool), | ||||||
|   ignoreIcloudAssets<bool>(122, type: bool), |   ignoreIcloudAssets<bool>(122, type: bool), | ||||||
|   selectedAlbumSortReverse<bool>(123, type: bool), |   selectedAlbumSortReverse<bool>(123, type: bool), | ||||||
|  |   mapThemeMode<int>(124, type: int), | ||||||
|   ; |   ; | ||||||
|  |  | ||||||
|   const StoreKey( |   const StoreKey( | ||||||
|   | |||||||
| @@ -94,7 +94,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | |||||||
|  |  | ||||||
|   final _log = Logger('WebsocketNotifier'); |   final _log = Logger('WebsocketNotifier'); | ||||||
|   final Ref _ref; |   final Ref _ref; | ||||||
|   final Debounce _debounce = Debounce(const Duration(milliseconds: 500)); |   final Debouncer _debounce = | ||||||
|  |       Debouncer(interval: const Duration(milliseconds: 500)); | ||||||
|  |  | ||||||
|   /// Connects websocket to server unless already connected |   /// Connects websocket to server unless already connected | ||||||
|   void connect() { |   void connect() { | ||||||
| @@ -194,7 +195,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | |||||||
|         PendingChange(now.millisecondsSinceEpoch.toString(), action, value), |         PendingChange(now.millisecondsSinceEpoch.toString(), action, value), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|     _debounce(handlePendingChanges); |     _debounce.run(handlePendingChanges); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> _handlePendingDeletes() async { |   Future<void> _handlePendingDeletes() async { | ||||||
|   | |||||||
| @@ -11,8 +11,8 @@ import 'package:immich_mobile/shared/providers/db.provider.dart'; | |||||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | import 'package:immich_mobile/shared/services/api.service.dart'; | ||||||
| import 'package:immich_mobile/shared/services/sync.service.dart'; | import 'package:immich_mobile/shared/services/sync.service.dart'; | ||||||
| import 'package:isar/isar.dart'; | import 'package:isar/isar.dart'; | ||||||
| import 'package:latlong2/latlong.dart'; |  | ||||||
| import 'package:logging/logging.dart'; | import 'package:logging/logging.dart'; | ||||||
|  | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
| import 'package:openapi/api.dart'; | import 'package:openapi/api.dart'; | ||||||
|  |  | ||||||
| final assetServiceProvider = Provider( | final assetServiceProvider = Provider( | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
|  |  | ||||||
| class CustomDraggingHandle extends StatelessWidget { | class CustomDraggingHandle extends StatelessWidget { | ||||||
|   const CustomDraggingHandle({super.key}); |   const CustomDraggingHandle({super.key}); | ||||||
| @@ -6,11 +7,11 @@ class CustomDraggingHandle extends StatelessWidget { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Container( |     return Container( | ||||||
|       height: 5, |       height: 4, | ||||||
|       width: 30, |       width: 30, | ||||||
|       decoration: BoxDecoration( |       decoration: BoxDecoration( | ||||||
|         color: Colors.grey[500], |         color: context.themeData.dividerColor, | ||||||
|         borderRadius: BorderRadius.circular(16), |         borderRadius: const BorderRadius.all(Radius.circular(20)), | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -3,12 +3,11 @@ import 'package:easy_localization/easy_localization.dart'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:flutter_map/plugin_api.dart'; |  | ||||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; | import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||||
| import 'package:immich_mobile/extensions/string_extensions.dart'; | import 'package:immich_mobile/extensions/string_extensions.dart'; | ||||||
| import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; | import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; | ||||||
| import 'package:immich_mobile/routing/router.dart'; | import 'package:immich_mobile/routing/router.dart'; | ||||||
| import 'package:latlong2/latlong.dart'; | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
| Future<LatLng?> showLocationPicker({ | Future<LatLng?> showLocationPicker({ | ||||||
|   required BuildContext context, |   required BuildContext context, | ||||||
| @@ -25,16 +24,6 @@ Future<LatLng?> showLocationPicker({ | |||||||
|  |  | ||||||
| enum _LocationPickerMode { map, manual } | enum _LocationPickerMode { map, manual } | ||||||
|  |  | ||||||
| bool _validateLat(String value) { |  | ||||||
|   final l = double.tryParse(value); |  | ||||||
|   return l != null && l > -90 && l < 90; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| bool _validateLong(String value) { |  | ||||||
|   final l = double.tryParse(value); |  | ||||||
|   return l != null && l > -180 && l < 180; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _LocationPicker extends HookWidget { | class _LocationPicker extends HookWidget { | ||||||
|   final LatLng? initialLatLng; |   final LatLng? initialLatLng; | ||||||
|  |  | ||||||
| @@ -48,187 +37,35 @@ class _LocationPicker extends HookWidget { | |||||||
|     final longitude = useState(initialLatLng?.longitude ?? 0.0); |     final longitude = useState(initialLatLng?.longitude ?? 0.0); | ||||||
|     final latlng = LatLng(latitude.value, longitude.value); |     final latlng = LatLng(latitude.value, longitude.value); | ||||||
|     final pickerMode = useState(_LocationPickerMode.map); |     final pickerMode = useState(_LocationPickerMode.map); | ||||||
|     final latitudeController = useTextEditingController(); |  | ||||||
|     final isValidLatitude = useState(true); |  | ||||||
|     final latitiudeFocusNode = useFocusNode(); |  | ||||||
|     final longitudeController = useTextEditingController(); |  | ||||||
|     final longitudeFocusNode = useFocusNode(); |  | ||||||
|     final isValidLongitude = useState(true); |  | ||||||
|  |  | ||||||
|     void validateInputs() { |     Future<void> onMapTap() async { | ||||||
|       isValidLatitude.value = _validateLat(latitudeController.text); |       final newLatLng = await context.pushRoute<LatLng?>( | ||||||
|       if (isValidLatitude.value) { |         MapLocationPickerRoute(initialLatLng: latlng), | ||||||
|         latitude.value = latitudeController.text.toDouble(); |       ); | ||||||
|  |       if (newLatLng != null) { | ||||||
|  |         latitude.value = newLatLng.latitude; | ||||||
|  |         longitude.value = newLatLng.longitude; | ||||||
|       } |       } | ||||||
|       isValidLongitude.value = _validateLong(longitudeController.text); |  | ||||||
|       if (isValidLongitude.value) { |  | ||||||
|         longitude.value = longitudeController.text.toDouble(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     void validateAndPop() { |  | ||||||
|       if (pickerMode.value == _LocationPickerMode.manual) { |  | ||||||
|         validateInputs(); |  | ||||||
|       } |  | ||||||
|       if (isValidLatitude.value && isValidLongitude.value) { |  | ||||||
|         return context.pop(latlng); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     List<Widget> buildMapPickerMode() { |  | ||||||
|       return [ |  | ||||||
|         TextButton.icon( |  | ||||||
|           icon: Text( |  | ||||||
|             "${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}", |  | ||||||
|           ), |  | ||||||
|           label: const Icon(Icons.edit_outlined, size: 16), |  | ||||||
|           onPressed: () { |  | ||||||
|             latitudeController.text = latitude.value.toStringAsFixed(4); |  | ||||||
|             longitudeController.text = longitude.value.toStringAsFixed(4); |  | ||||||
|             pickerMode.value = _LocationPickerMode.manual; |  | ||||||
|           }, |  | ||||||
|         ), |  | ||||||
|         const SizedBox( |  | ||||||
|           height: 12, |  | ||||||
|         ), |  | ||||||
|         MapThumbnail( |  | ||||||
|           coords: latlng, |  | ||||||
|           height: 200, |  | ||||||
|           width: 200, |  | ||||||
|           zoom: 6, |  | ||||||
|           showAttribution: false, |  | ||||||
|           onTap: (p0, p1) async { |  | ||||||
|             final newLatLng = await context.pushRoute<LatLng?>( |  | ||||||
|               MapLocationPickerRoute(initialLatLng: latlng), |  | ||||||
|             ); |  | ||||||
|             if (newLatLng != null) { |  | ||||||
|               latitude.value = newLatLng.latitude; |  | ||||||
|               longitude.value = newLatLng.longitude; |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|           markers: [ |  | ||||||
|             Marker( |  | ||||||
|               anchorPos: AnchorPos.align(AnchorAlign.top), |  | ||||||
|               point: LatLng( |  | ||||||
|                 latitude.value, |  | ||||||
|                 longitude.value, |  | ||||||
|               ), |  | ||||||
|               builder: (ctx) => const Image( |  | ||||||
|                 image: AssetImage('assets/location-pin.png'), |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|       ]; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     List<Widget> buildManualPickerMode() { |  | ||||||
|       return [ |  | ||||||
|         TextButton.icon( |  | ||||||
|           icon: const Text("location_picker_choose_on_map").tr(), |  | ||||||
|           label: const Icon(Icons.map_outlined, size: 16), |  | ||||||
|           onPressed: () { |  | ||||||
|             validateInputs(); |  | ||||||
|             if (isValidLatitude.value && isValidLongitude.value) { |  | ||||||
|               pickerMode.value = _LocationPickerMode.map; |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|         ), |  | ||||||
|         const SizedBox( |  | ||||||
|           height: 12, |  | ||||||
|         ), |  | ||||||
|         TextField( |  | ||||||
|           controller: latitudeController, |  | ||||||
|           focusNode: latitiudeFocusNode, |  | ||||||
|           textInputAction: TextInputAction.done, |  | ||||||
|           autofocus: false, |  | ||||||
|           decoration: InputDecoration( |  | ||||||
|             labelText: 'location_picker_latitude'.tr(), |  | ||||||
|             labelStyle: TextStyle( |  | ||||||
|               fontWeight: FontWeight.bold, |  | ||||||
|               color: context.primaryColor, |  | ||||||
|             ), |  | ||||||
|             floatingLabelBehavior: FloatingLabelBehavior.auto, |  | ||||||
|             border: const OutlineInputBorder(), |  | ||||||
|             hintText: 'location_picker_latitude_hint'.tr(), |  | ||||||
|             hintStyle: const TextStyle( |  | ||||||
|               fontWeight: FontWeight.normal, |  | ||||||
|               fontSize: 14, |  | ||||||
|             ), |  | ||||||
|             errorText: isValidLatitude.value |  | ||||||
|                 ? null |  | ||||||
|                 : "location_picker_latitude_error".tr(), |  | ||||||
|           ), |  | ||||||
|           onEditingComplete: () { |  | ||||||
|             isValidLatitude.value = _validateLat(latitudeController.text); |  | ||||||
|             if (isValidLatitude.value) { |  | ||||||
|               latitude.value = latitudeController.text.toDouble(); |  | ||||||
|               longitudeFocusNode.requestFocus(); |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|           keyboardType: const TextInputType.numberWithOptions(decimal: true), |  | ||||||
|           inputFormatters: [LengthLimitingTextInputFormatter(8)], |  | ||||||
|           onTapOutside: (_) => latitiudeFocusNode.unfocus(), |  | ||||||
|         ), |  | ||||||
|         const SizedBox( |  | ||||||
|           height: 24, |  | ||||||
|         ), |  | ||||||
|         TextField( |  | ||||||
|           controller: longitudeController, |  | ||||||
|           focusNode: longitudeFocusNode, |  | ||||||
|           textInputAction: TextInputAction.done, |  | ||||||
|           autofocus: false, |  | ||||||
|           decoration: InputDecoration( |  | ||||||
|             labelText: 'location_picker_longitude'.tr(), |  | ||||||
|             labelStyle: TextStyle( |  | ||||||
|               fontWeight: FontWeight.bold, |  | ||||||
|               color: context.primaryColor, |  | ||||||
|             ), |  | ||||||
|             floatingLabelBehavior: FloatingLabelBehavior.auto, |  | ||||||
|             border: const OutlineInputBorder(), |  | ||||||
|             hintText: 'location_picker_longitude_hint'.tr(), |  | ||||||
|             hintStyle: const TextStyle( |  | ||||||
|               fontWeight: FontWeight.normal, |  | ||||||
|               fontSize: 14, |  | ||||||
|             ), |  | ||||||
|             errorText: isValidLongitude.value |  | ||||||
|                 ? null |  | ||||||
|                 : "location_picker_longitude_error".tr(), |  | ||||||
|           ), |  | ||||||
|           onEditingComplete: () { |  | ||||||
|             isValidLongitude.value = _validateLong(longitudeController.text); |  | ||||||
|             if (isValidLongitude.value) { |  | ||||||
|               longitude.value = longitudeController.text.toDouble(); |  | ||||||
|               longitudeFocusNode.unfocus(); |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|           keyboardType: const TextInputType.numberWithOptions(decimal: true), |  | ||||||
|           inputFormatters: [LengthLimitingTextInputFormatter(8)], |  | ||||||
|           onTapOutside: (_) => longitudeFocusNode.unfocus(), |  | ||||||
|         ), |  | ||||||
|       ]; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return AlertDialog( |     return AlertDialog( | ||||||
|       contentPadding: const EdgeInsets.all(30), |       contentPadding: const EdgeInsets.all(30), | ||||||
|       alignment: Alignment.center, |       alignment: Alignment.center, | ||||||
|       content: SingleChildScrollView( |       content: SingleChildScrollView( | ||||||
|         child: Column( |         child: pickerMode.value == _LocationPickerMode.map | ||||||
|           mainAxisSize: MainAxisSize.min, |             ? _MapPicker( | ||||||
|           children: [ |                 key: ValueKey(latlng), | ||||||
|             const Text( |                 latlng: latlng, | ||||||
|               "edit_location_dialog_title", |                 onModeSwitch: () => | ||||||
|               textAlign: TextAlign.center, |                     pickerMode.value = _LocationPickerMode.manual, | ||||||
|             ).tr(), |                 onMapTap: onMapTap, | ||||||
|             const SizedBox( |               ) | ||||||
|               height: 12, |             : _ManualPicker( | ||||||
|             ), |                 latlng: latlng, | ||||||
|             if (pickerMode.value == _LocationPickerMode.manual) |                 onModeSwitch: () => pickerMode.value = _LocationPickerMode.map, | ||||||
|               ...buildManualPickerMode(), |                 onLatUpdated: (value) => latitude.value = value, | ||||||
|             if (pickerMode.value == _LocationPickerMode.map) |                 onLonUpdated: (value) => longitude.value = value, | ||||||
|               ...buildMapPickerMode(), |               ), | ||||||
|           ], |  | ||||||
|         ), |  | ||||||
|       ), |       ), | ||||||
|       actions: [ |       actions: [ | ||||||
|         TextButton( |         TextButton( | ||||||
| @@ -242,7 +79,7 @@ class _LocationPicker extends HookWidget { | |||||||
|           ).tr(), |           ).tr(), | ||||||
|         ), |         ), | ||||||
|         TextButton( |         TextButton( | ||||||
|           onPressed: validateAndPop, |           onPressed: () => context.popRoute(latlng), | ||||||
|           child: Text( |           child: Text( | ||||||
|             "action_common_update", |             "action_common_update", | ||||||
|             style: context.textTheme.bodyMedium?.copyWith( |             style: context.textTheme.bodyMedium?.copyWith( | ||||||
| @@ -255,3 +92,177 @@ class _LocationPicker extends HookWidget { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class _ManualPickerInput extends HookWidget { | ||||||
|  |   final String initialValue; | ||||||
|  |   final String decorationText; | ||||||
|  |   final String hintText; | ||||||
|  |   final String errorText; | ||||||
|  |   final FocusNode focusNode; | ||||||
|  |   final bool Function(String value) validator; | ||||||
|  |   final Function(double value) onUpdated; | ||||||
|  |  | ||||||
|  |   const _ManualPickerInput({ | ||||||
|  |     required this.initialValue, | ||||||
|  |     required this.decorationText, | ||||||
|  |     required this.hintText, | ||||||
|  |     required this.errorText, | ||||||
|  |     required this.focusNode, | ||||||
|  |     required this.validator, | ||||||
|  |     required this.onUpdated, | ||||||
|  |   }); | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final isValid = useState(true); | ||||||
|  |     final controller = useTextEditingController(text: initialValue); | ||||||
|  |  | ||||||
|  |     void onEditingComplete() { | ||||||
|  |       isValid.value = validator(controller.text); | ||||||
|  |       if (isValid.value) { | ||||||
|  |         onUpdated(controller.text.toDouble()); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return TextField( | ||||||
|  |       controller: controller, | ||||||
|  |       focusNode: focusNode, | ||||||
|  |       textInputAction: TextInputAction.done, | ||||||
|  |       autofocus: false, | ||||||
|  |       decoration: InputDecoration( | ||||||
|  |         labelText: decorationText.tr(), | ||||||
|  |         labelStyle: TextStyle( | ||||||
|  |           fontWeight: FontWeight.bold, | ||||||
|  |           color: context.primaryColor, | ||||||
|  |         ), | ||||||
|  |         floatingLabelBehavior: FloatingLabelBehavior.auto, | ||||||
|  |         border: const OutlineInputBorder(), | ||||||
|  |         hintText: hintText.tr(), | ||||||
|  |         hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), | ||||||
|  |         errorText: isValid.value ? null : errorText.tr(), | ||||||
|  |       ), | ||||||
|  |       onEditingComplete: onEditingComplete, | ||||||
|  |       keyboardType: const TextInputType.numberWithOptions(decimal: true), | ||||||
|  |       inputFormatters: [LengthLimitingTextInputFormatter(8)], | ||||||
|  |       onTapOutside: (_) => focusNode.unfocus(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ManualPicker extends HookWidget { | ||||||
|  |   final LatLng latlng; | ||||||
|  |   final Function() onModeSwitch; | ||||||
|  |   final Function(double) onLatUpdated; | ||||||
|  |   final Function(double) onLonUpdated; | ||||||
|  |  | ||||||
|  |   const _ManualPicker({ | ||||||
|  |     required this.latlng, | ||||||
|  |     required this.onModeSwitch, | ||||||
|  |     required this.onLatUpdated, | ||||||
|  |     required this.onLonUpdated, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   bool _validateLat(String value) { | ||||||
|  |     final l = double.tryParse(value); | ||||||
|  |     return l != null && l > -90 && l < 90; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool _validateLong(String value) { | ||||||
|  |     final l = double.tryParse(value); | ||||||
|  |     return l != null && l > -180 && l < 180; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final latitiudeFocusNode = useFocusNode(); | ||||||
|  |     final longitudeFocusNode = useFocusNode(); | ||||||
|  |  | ||||||
|  |     void onLatitudeUpdated(double value) { | ||||||
|  |       onLatUpdated(value); | ||||||
|  |       longitudeFocusNode.requestFocus(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     void onLongitudeEditingCompleted(double value) { | ||||||
|  |       onLonUpdated(value); | ||||||
|  |       longitudeFocusNode.unfocus(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Column( | ||||||
|  |       mainAxisSize: MainAxisSize.min, | ||||||
|  |       children: [ | ||||||
|  |         const Text( | ||||||
|  |           "edit_location_dialog_title", | ||||||
|  |           textAlign: TextAlign.center, | ||||||
|  |         ).tr(), | ||||||
|  |         const SizedBox(height: 12), | ||||||
|  |         TextButton.icon( | ||||||
|  |           icon: const Text("location_picker_choose_on_map").tr(), | ||||||
|  |           label: const Icon(Icons.map_outlined, size: 16), | ||||||
|  |           onPressed: onModeSwitch, | ||||||
|  |         ), | ||||||
|  |         const SizedBox(height: 12), | ||||||
|  |         _ManualPickerInput( | ||||||
|  |           initialValue: latlng.latitude.toStringAsFixed(4), | ||||||
|  |           decorationText: "location_picker_latitude", | ||||||
|  |           hintText: "location_picker_latitude_hint", | ||||||
|  |           errorText: "location_picker_latitude_error", | ||||||
|  |           focusNode: latitiudeFocusNode, | ||||||
|  |           validator: _validateLat, | ||||||
|  |           onUpdated: onLatitudeUpdated, | ||||||
|  |         ), | ||||||
|  |         const SizedBox(height: 24), | ||||||
|  |         _ManualPickerInput( | ||||||
|  |           initialValue: latlng.longitude.toStringAsFixed(4), | ||||||
|  |           decorationText: "location_picker_longitude", | ||||||
|  |           hintText: "location_picker_longitude_hint", | ||||||
|  |           errorText: "location_picker_longitude_error", | ||||||
|  |           focusNode: latitiudeFocusNode, | ||||||
|  |           validator: _validateLong, | ||||||
|  |           onUpdated: onLongitudeEditingCompleted, | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _MapPicker extends StatelessWidget { | ||||||
|  |   final LatLng latlng; | ||||||
|  |   final Function() onModeSwitch; | ||||||
|  |   final Function() onMapTap; | ||||||
|  |  | ||||||
|  |   const _MapPicker({ | ||||||
|  |     required this.latlng, | ||||||
|  |     required this.onModeSwitch, | ||||||
|  |     required this.onMapTap, | ||||||
|  |     super.key, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return Column( | ||||||
|  |       mainAxisSize: MainAxisSize.min, | ||||||
|  |       children: [ | ||||||
|  |         const Text( | ||||||
|  |           "edit_location_dialog_title", | ||||||
|  |           textAlign: TextAlign.center, | ||||||
|  |         ).tr(), | ||||||
|  |         const SizedBox(height: 12), | ||||||
|  |         TextButton.icon( | ||||||
|  |           icon: Text( | ||||||
|  |             "${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}", | ||||||
|  |           ), | ||||||
|  |           label: const Icon(Icons.edit_outlined, size: 16), | ||||||
|  |           onPressed: onModeSwitch, | ||||||
|  |         ), | ||||||
|  |         const SizedBox(height: 12), | ||||||
|  |         MapThumbnail( | ||||||
|  |           centre: latlng, | ||||||
|  |           height: 200, | ||||||
|  |           width: 200, | ||||||
|  |           zoom: 8, | ||||||
|  |           showMarkerPin: true, | ||||||
|  |           onTap: (_, __) => onMapTap(), | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,26 +1,61 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  |  | ||||||
| class Debounce { | /// Used to debounce function calls with the [interval] provided. | ||||||
|   Debounce(Duration interval) : _interval = interval.inMilliseconds; | class Debouncer { | ||||||
|   final int _interval; |   Debouncer({required this.interval}); | ||||||
|  |   final Duration interval; | ||||||
|   Timer? _timer; |   Timer? _timer; | ||||||
|   VoidCallback? action; |   FutureOr<void> Function()? _lastAction; | ||||||
|  |  | ||||||
|   void call(VoidCallback? action) { |   void run(FutureOr<void> Function() action) { | ||||||
|     this.action = action; |     _lastAction = action; | ||||||
|     _timer?.cancel(); |     _timer?.cancel(); | ||||||
|     _timer = Timer(Duration(milliseconds: _interval), _callAndRest); |     _timer = Timer(interval, _callAndRest); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _callAndRest() { |   void _callAndRest() { | ||||||
|     action?.call(); |     _lastAction?.call(); | ||||||
|     _timer = null; |     _timer = null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     _timer?.cancel(); |     _timer?.cancel(); | ||||||
|     _timer = null; |     _timer = null; | ||||||
|  |     _lastAction = null; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a | ||||||
|  | /// default interval of 300ms is used to debounce the function calls | ||||||
|  | Debouncer useDebouncer({ | ||||||
|  |   Duration interval = const Duration(milliseconds: 300), | ||||||
|  |   List<Object?>? keys, | ||||||
|  | }) => | ||||||
|  |     use(_DebouncerHook(interval: interval, keys: keys)); | ||||||
|  |  | ||||||
|  | class _DebouncerHook extends Hook<Debouncer> { | ||||||
|  |   const _DebouncerHook({ | ||||||
|  |     required this.interval, | ||||||
|  |     List<Object?>? keys, | ||||||
|  |   }) : super(keys: keys); | ||||||
|  |  | ||||||
|  |   final Duration interval; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> { | ||||||
|  |   late final debouncer = Debouncer(interval: hook.interval); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Debouncer build(_) => debouncer; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() => debouncer.dispose(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String get debugLabel => 'useDebouncer'; | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										41
									
								
								mobile/lib/utils/draggable_scroll_controller.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								mobile/lib/utils/draggable_scroll_controller.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  |  | ||||||
|  | /// Creates a [DraggableScrollableController] that will be disposed automatically. | ||||||
|  | /// | ||||||
|  | /// See also: | ||||||
|  | /// - [DraggableScrollableController] | ||||||
|  | DraggableScrollableController useDraggableScrollController({ | ||||||
|  |   List<Object?>? keys, | ||||||
|  | }) { | ||||||
|  |   return use( | ||||||
|  |     _DraggableScrollControllerHook( | ||||||
|  |       keys: keys, | ||||||
|  |     ), | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _DraggableScrollControllerHook | ||||||
|  |     extends Hook<DraggableScrollableController> { | ||||||
|  |   const _DraggableScrollControllerHook({ | ||||||
|  |     List<Object?>? keys, | ||||||
|  |   }) : super(keys: keys); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   HookState<DraggableScrollableController, Hook<DraggableScrollableController>> | ||||||
|  |       createState() => _DraggableScrollControllerHookState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _DraggableScrollControllerHookState extends HookState< | ||||||
|  |     DraggableScrollableController, _DraggableScrollControllerHook> { | ||||||
|  |   late final controller = DraggableScrollableController(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   DraggableScrollableController build(BuildContext context) => controller; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() => controller.dispose(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String get debugLabel => 'useDraggableScrollController'; | ||||||
|  | } | ||||||
| @@ -12,7 +12,7 @@ import 'package:immich_mobile/shared/ui/date_time_picker.dart'; | |||||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||||
| import 'package:immich_mobile/shared/ui/location_picker.dart'; | import 'package:immich_mobile/shared/ui/location_picker.dart'; | ||||||
| import 'package:immich_mobile/shared/ui/share_dialog.dart'; | import 'package:immich_mobile/shared/ui/share_dialog.dart'; | ||||||
| import 'package:latlong2/latlong.dart'; | import 'package:maplibre_gl/maplibre_gl.dart'; | ||||||
|  |  | ||||||
| void handleShareAssets( | void handleShareAssets( | ||||||
|   WidgetRef ref, |   WidgetRef ref, | ||||||
|   | |||||||
							
								
								
									
										57
									
								
								mobile/lib/utils/throttle.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								mobile/lib/utils/throttle.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | import 'dart:async'; | ||||||
|  |  | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  |  | ||||||
|  | /// Throttles function calls with the [interval] provided. | ||||||
|  | /// Also make sures to call the last Action after the elapsed interval | ||||||
|  | class Throttler { | ||||||
|  |   final Duration interval; | ||||||
|  |   DateTime? _lastActionTime; | ||||||
|  |  | ||||||
|  |   Throttler({required this.interval}); | ||||||
|  |  | ||||||
|  |   void run(FutureOr<void> Function() action) { | ||||||
|  |     if (_lastActionTime == null || | ||||||
|  |         (DateTime.now().difference(_lastActionTime!) > interval)) { | ||||||
|  |       action(); | ||||||
|  |       _lastActionTime = DateTime.now(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void dispose() { | ||||||
|  |     _lastActionTime = null; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Creates a [Throttler] that will be disposed automatically. If no [interval] is provided, a | ||||||
|  | /// default interval of 300ms is used to throttle the function calls | ||||||
|  | Throttler useThrottler({ | ||||||
|  |   Duration interval = const Duration(milliseconds: 300), | ||||||
|  |   List<Object?>? keys, | ||||||
|  | }) => | ||||||
|  |     use(_ThrottleHook(interval: interval, keys: keys)); | ||||||
|  |  | ||||||
|  | class _ThrottleHook extends Hook<Throttler> { | ||||||
|  |   const _ThrottleHook({ | ||||||
|  |     required this.interval, | ||||||
|  |     List<Object?>? keys, | ||||||
|  |   }) : super(keys: keys); | ||||||
|  |  | ||||||
|  |   final Duration interval; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   HookState<Throttler, Hook<Throttler>> createState() => _ThrottlerHookState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ThrottlerHookState extends HookState<Throttler, _ThrottleHook> { | ||||||
|  |   late final throttler = Throttler(interval: hook.interval); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Throttler build(_) => throttler; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() => throttler.dispose(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String get debugLabel => 'useThrottler'; | ||||||
|  | } | ||||||
| @@ -25,14 +25,22 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.11.2" |     version: "0.11.2" | ||||||
|  |   ansicolor: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: ansicolor | ||||||
|  |       sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.0.2" | ||||||
|   archive: |   archive: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: archive |       name: archive | ||||||
|       sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" |       sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.3.7" |     version: "3.4.9" | ||||||
|   args: |   args: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -385,14 +393,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.0.2" |     version: "0.0.2" | ||||||
|   executor_lib: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: executor_lib |  | ||||||
|       sha256: "544889daa5726462657dab6410b75f2f8e3a77479d85b307a25c346e243bc38e" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "1.1.1" |  | ||||||
|   fake_async: |   fake_async: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -503,10 +503,10 @@ packages: | |||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
|       name: flutter_launcher_icons |       name: flutter_launcher_icons | ||||||
|       sha256: "559c600f056e7c704bd843723c21e01b5fba47e8824bd02422165bcc02a5de1d" |       sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.9.3" |     version: "0.13.1" | ||||||
|   flutter_lints: |   flutter_lints: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
| @@ -544,30 +544,14 @@ packages: | |||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     version: "0.0.0" | ||||||
|   flutter_map: |  | ||||||
|     dependency: "direct main" |  | ||||||
|     description: |  | ||||||
|       name: flutter_map |  | ||||||
|       sha256: "52c65a977daae42f9aae6748418dd1535eaf27186e9bac9bf431843082bc75a3" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "4.0.0" |  | ||||||
|   flutter_map_heatmap: |  | ||||||
|     dependency: "direct main" |  | ||||||
|     description: |  | ||||||
|       name: flutter_map_heatmap |  | ||||||
|       sha256: "2d16cf5bf41f40a79ae79bcdf2afc92ec45fea0cc311b3a51e3eae661392df88" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "0.0.4+2" |  | ||||||
|   flutter_native_splash: |   flutter_native_splash: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
|       name: flutter_native_splash |       name: flutter_native_splash | ||||||
|       sha256: "6777a3abb974021a39b5fdd2d46a03ca390e03903b6351f21d10e7ecc969f12d" |       sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.2.16" |     version: "2.3.7" | ||||||
|   flutter_plugin_android_lifecycle: |   flutter_plugin_android_lifecycle: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -755,10 +739,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: image |       name: image | ||||||
|       sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6" |       sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.3.0" |     version: "4.1.3" | ||||||
|   image_picker: |   image_picker: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -884,14 +868,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.8.1" |     version: "4.8.1" | ||||||
|   latlong2: |  | ||||||
|     dependency: "direct main" |  | ||||||
|     description: |  | ||||||
|       name: latlong2 |  | ||||||
|       sha256: "08ef7282ba9f76e8495e49e2dc4d653015ac929dce5f92b375a415d30b407ea0" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "0.8.2" |  | ||||||
|   lints: |   lints: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -900,14 +876,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.1" |     version: "2.1.1" | ||||||
|   lists: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: lists |  | ||||||
|       sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "1.0.1" |  | ||||||
|   logging: |   logging: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -916,6 +884,33 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.2.0" |     version: "1.2.0" | ||||||
|  |   maplibre_gl: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       path: "." | ||||||
|  |       ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 | ||||||
|  |       resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 | ||||||
|  |       url: "https://github.com/maplibre/flutter-maplibre-gl.git" | ||||||
|  |     source: git | ||||||
|  |     version: "0.18.0" | ||||||
|  |   maplibre_gl_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       path: maplibre_gl_platform_interface | ||||||
|  |       ref: main | ||||||
|  |       resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 | ||||||
|  |       url: "https://github.com/maplibre/flutter-maplibre-gl.git" | ||||||
|  |     source: git | ||||||
|  |     version: "0.18.0" | ||||||
|  |   maplibre_gl_web: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       path: maplibre_gl_web | ||||||
|  |       ref: main | ||||||
|  |       resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 | ||||||
|  |       url: "https://github.com/maplibre/flutter-maplibre-gl.git" | ||||||
|  |     source: git | ||||||
|  |     version: "0.18.0" | ||||||
|   matcher: |   matcher: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -940,14 +935,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.1" |     version: "1.9.1" | ||||||
|   mgrs_dart: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: mgrs_dart |  | ||||||
|       sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "2.0.0" |  | ||||||
|   mime: |   mime: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1163,14 +1150,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.7.3" |     version: "3.7.3" | ||||||
|   polylabel: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: polylabel |  | ||||||
|       sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "1.0.1" |  | ||||||
|   pool: |   pool: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1187,22 +1166,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.2.4" |     version: "4.2.4" | ||||||
|   proj4dart: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: proj4dart |  | ||||||
|       sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "2.1.0" |  | ||||||
|   protobuf: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: protobuf |  | ||||||
|       sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "2.1.0" |  | ||||||
|   provider: |   provider: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1520,14 +1483,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.0.1" |     version: "1.0.1" | ||||||
|   tuple: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: tuple |  | ||||||
|       sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "2.0.2" |  | ||||||
|   typed_data: |   typed_data: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1536,14 +1491,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.3.2" |     version: "1.3.2" | ||||||
|   unicode: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: unicode |  | ||||||
|       sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "0.3.1" |  | ||||||
|   universal_io: |   universal_io: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1624,15 +1571,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.7" |     version: "3.0.7" | ||||||
|   vector_map_tiles: |  | ||||||
|     dependency: "direct main" |  | ||||||
|     description: |  | ||||||
|       path: "." |  | ||||||
|       ref: immich_above_4 |  | ||||||
|       resolved-ref: dc685bdbcca2ff2b49b4d0fb77b7bc17fad48608 |  | ||||||
|       url: "https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git" |  | ||||||
|     source: git |  | ||||||
|     version: "4.0.0" |  | ||||||
|   vector_math: |   vector_math: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -1641,22 +1579,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.4" |     version: "2.1.4" | ||||||
|   vector_tile: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: vector_tile |  | ||||||
|       sha256: "2ac77f6bbd7ddd97efe059207d67bb7eaf807ab98ad58d99fe41a22c230f44e1" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "1.0.0" |  | ||||||
|   vector_tile_renderer: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: vector_tile_renderer |  | ||||||
|       sha256: de212da0f5e48107d3b763a940a428eb1f49d8a4664d41ac0b654f77209a2d0b |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "4.0.0" |  | ||||||
|   video_player: |   video_player: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -1761,14 +1683,6 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.1.4" |     version: "4.1.4" | ||||||
|   wkt_parser: |  | ||||||
|     dependency: transitive |  | ||||||
|     description: |  | ||||||
|       name: wkt_parser |  | ||||||
|       sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" |  | ||||||
|       url: "https://pub.dev" |  | ||||||
|     source: hosted |  | ||||||
|     version: "2.0.0" |  | ||||||
|   xdg_directories: |   xdg_directories: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|   | |||||||
| @@ -25,13 +25,12 @@ dependencies: | |||||||
|   video_player: ^2.2.18 |   video_player: ^2.2.18 | ||||||
|   chewie: ^1.4.0 |   chewie: ^1.4.0 | ||||||
|   socket_io_client: ^2.0.0-beta.4-nullsafety.0 |   socket_io_client: ^2.0.0-beta.4-nullsafety.0 | ||||||
|   flutter_map: ^4.0.0 |   # Update it to tag once next stable release | ||||||
|   flutter_map_heatmap: ^0.0.4 |   maplibre_gl: | ||||||
|  |       git: | ||||||
|  |         url: https://github.com/maplibre/flutter-maplibre-gl.git | ||||||
|  |         ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 | ||||||
|   geolocator: ^10.0.0 # used to move to current location in map view |   geolocator: ^10.0.0 # used to move to current location in map view | ||||||
|   vector_map_tiles: |  | ||||||
|     git: |  | ||||||
|       url: https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git |  | ||||||
|       ref: immich_above_4 |  | ||||||
|   flutter_udid: ^2.0.0 |   flutter_udid: ^2.0.0 | ||||||
|   package_info_plus: ^4.1.0 |   package_info_plus: ^4.1.0 | ||||||
|   url_launcher: ^6.1.3 |   url_launcher: ^6.1.3 | ||||||
| @@ -40,10 +39,9 @@ dependencies: | |||||||
|   easy_localization: ^3.0.1 |   easy_localization: ^3.0.1 | ||||||
|   share_plus: ^7.1.0 |   share_plus: ^7.1.0 | ||||||
|   flutter_displaymode: ^0.4.0 |   flutter_displaymode: ^0.4.0 | ||||||
|   scrollable_positioned_list: ^0.3.4 |   scrollable_positioned_list: ^0.3.8 | ||||||
|   path: ^1.8.1 |   path: ^1.8.1 | ||||||
|   path_provider: ^2.0.11 |   path_provider: ^2.0.11 | ||||||
|   latlong2: ^0.8.1 |  | ||||||
|   collection: ^1.16.0 |   collection: ^1.16.0 | ||||||
|   http_parser: ^4.0.1 |   http_parser: ^4.0.1 | ||||||
|   flutter_web_auth: ^0.5.0 |   flutter_web_auth: ^0.5.0 | ||||||
| @@ -79,7 +77,7 @@ dev_dependencies: | |||||||
|   flutter_lints: ^2.0.1 |   flutter_lints: ^2.0.1 | ||||||
|   build_runner: ^2.2.1 |   build_runner: ^2.2.1 | ||||||
|   auto_route_generator: ^5.0.2 |   auto_route_generator: ^5.0.2 | ||||||
|   flutter_launcher_icons: "^0.9.2" |   flutter_launcher_icons: ^0.13.1 | ||||||
|   flutter_native_splash: ^2.2.16 |   flutter_native_splash: ^2.2.16 | ||||||
|   isar_generator: *isar_version |   isar_generator: *isar_version | ||||||
|   integration_test: |   integration_test: | ||||||
| @@ -117,11 +115,12 @@ flutter: | |||||||
|       fonts: |       fonts: | ||||||
|         - asset: fonts/overpass/OverpassMono.ttf |         - asset: fonts/overpass/OverpassMono.ttf | ||||||
|  |  | ||||||
| flutter_icons: | flutter_launcher_icons: | ||||||
|   image_path_android: "assets/immich-logo-no-outline.png" |   image_path_android: "assets/immich-logo-no-outline.png" | ||||||
|   image_path_ios: "assets/immich-logo-no-outline.png" |   image_path_ios: "assets/immich-logo-no-outline.png" | ||||||
|   android: true # can specify file name here e.g. "ic_launcher" |   android: true # can specify file name here e.g. "ic_launcher" | ||||||
|   ios: true # can specify file name here e.g. "My-Launcher-Icon |   ios: true # can specify file name here e.g. "My-Launcher-Icon | ||||||
|  |   remove_alpha_ios: true | ||||||
|  |  | ||||||
| analyzer: | analyzer: | ||||||
|   exclude: |   exclude: | ||||||
|   | |||||||
| @@ -203,7 +203,7 @@ void main() { | |||||||
|     late ProviderContainer container; |     late ProviderContainer container; | ||||||
|  |  | ||||||
|     setUp(() async { |     setUp(() async { | ||||||
|       settingsMock = AppSettingsServiceMock(); |       settingsMock = MockAppSettingsService(); | ||||||
|       container = TestUtils.createContainer( |       container = TestUtils.createContainer( | ||||||
|         overrides: [ |         overrides: [ | ||||||
|           appSettingsServiceProvider.overrideWith((ref) => settingsMock), |           appSettingsServiceProvider.overrideWith((ref) => settingsMock), | ||||||
| @@ -283,7 +283,7 @@ void main() { | |||||||
|     late ProviderContainer container; |     late ProviderContainer container; | ||||||
|  |  | ||||||
|     setUp(() async { |     setUp(() async { | ||||||
|       settingsMock = AppSettingsServiceMock(); |       settingsMock = MockAppSettingsService(); | ||||||
|       container = TestUtils.createContainer( |       container = TestUtils.createContainer( | ||||||
|         overrides: [ |         overrides: [ | ||||||
|           appSettingsServiceProvider.overrideWith((ref) => settingsMock), |           appSettingsServiceProvider.overrideWith((ref) => settingsMock), | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								mobile/test/modules/map/map_mocks.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								mobile/test/modules/map/map_mocks.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/models/map_state.model.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
|  | import 'package:mocktail/mocktail.dart'; | ||||||
|  |  | ||||||
|  | class MockMapStateNotifier extends Notifier<MapState> | ||||||
|  |     with Mock | ||||||
|  |     implements MapStateNotifier { | ||||||
|  |   final MapState initState; | ||||||
|  |  | ||||||
|  |   MockMapStateNotifier(this.initState); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   MapState build() => initState; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   set state(MapState mapState) => super.state = mapState; | ||||||
|  | } | ||||||
							
								
								
									
										165
									
								
								mobile/test/modules/map/map_theme_override_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								mobile/test/modules/map/map_theme_override_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | |||||||
|  | @Tags(['widget']) | ||||||
|  |  | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_test/flutter_test.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/models/map_state.model.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/providers/map_state.provider.dart'; | ||||||
|  | import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart'; | ||||||
|  |  | ||||||
|  | import '../../test_utils.dart'; | ||||||
|  | import '../../widget_tester_extensions.dart'; | ||||||
|  | import 'map_mocks.dart'; | ||||||
|  |  | ||||||
|  | void main() { | ||||||
|  |   late MockMapStateNotifier mapStateNotifier; | ||||||
|  |   late List<Override> overrides; | ||||||
|  |   late MapState mapState; | ||||||
|  |  | ||||||
|  |   setUpAll(() async { | ||||||
|  |     TestUtils.init(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   setUp(() { | ||||||
|  |     mapState = MapState(themeMode: ThemeMode.dark); | ||||||
|  |     mapStateNotifier = MockMapStateNotifier(mapState); | ||||||
|  |     overrides = [mapStateNotifierProvider.overrideWith(() => mapStateNotifier)]; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   testWidgets("Return dark theme style when theme mode is dark", | ||||||
|  |       (tester) async { | ||||||
|  |     AsyncValue<String>? mapStyle; | ||||||
|  |     await tester.pumpConsumerWidget( | ||||||
|  |       MapThemeOveride( | ||||||
|  |         mapBuilder: (AsyncValue<String> style) { | ||||||
|  |           mapStyle = style; | ||||||
|  |           return const Text("Mock"); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |       overrides: overrides, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     mapStateNotifier.state = | ||||||
|  |         mapState.copyWith(darkStyleFetched: const AsyncData("dark")); | ||||||
|  |     await tester.pumpAndSettle(); | ||||||
|  |     expect(mapStyle?.valueOrNull, "dark"); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   testWidgets("Return error when style is not fetched", (tester) async { | ||||||
|  |     AsyncValue<String>? mapStyle; | ||||||
|  |     await tester.pumpConsumerWidget( | ||||||
|  |       MapThemeOveride( | ||||||
|  |         mapBuilder: (AsyncValue<String> style) { | ||||||
|  |           mapStyle = style; | ||||||
|  |           return const Text("Mock"); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |       overrides: overrides, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     mapStateNotifier.state = mapState.copyWith( | ||||||
|  |       darkStyleFetched: const AsyncError("Error", StackTrace.empty), | ||||||
|  |     ); | ||||||
|  |     await tester.pumpAndSettle(); | ||||||
|  |     expect(mapStyle?.hasError, isTrue); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   testWidgets("Return light theme style when theme mode is light", | ||||||
|  |       (tester) async { | ||||||
|  |     AsyncValue<String>? mapStyle; | ||||||
|  |     await tester.pumpConsumerWidget( | ||||||
|  |       MapThemeOveride( | ||||||
|  |         mapBuilder: (AsyncValue<String> style) { | ||||||
|  |           mapStyle = style; | ||||||
|  |           return const Text("Mock"); | ||||||
|  |         }, | ||||||
|  |       ), | ||||||
|  |       overrides: overrides, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     mapStateNotifier.state = mapState.copyWith( | ||||||
|  |       themeMode: ThemeMode.light, | ||||||
|  |       lightStyleFetched: const AsyncData("light"), | ||||||
|  |     ); | ||||||
|  |     await tester.pumpAndSettle(); | ||||||
|  |     expect(mapStyle?.valueOrNull, "light"); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   group("System mode", () { | ||||||
|  |     testWidgets("Return dark theme style when system is dark", (tester) async { | ||||||
|  |       AsyncValue<String>? mapStyle; | ||||||
|  |       await tester.pumpConsumerWidget( | ||||||
|  |         MapThemeOveride( | ||||||
|  |           mapBuilder: (AsyncValue<String> style) { | ||||||
|  |             mapStyle = style; | ||||||
|  |             return const Text("Mock"); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         overrides: overrides, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       tester.binding.platformDispatcher.platformBrightnessTestValue = | ||||||
|  |           Brightness.dark; | ||||||
|  |       mapStateNotifier.state = mapState.copyWith( | ||||||
|  |         themeMode: ThemeMode.system, | ||||||
|  |         darkStyleFetched: const AsyncData("dark"), | ||||||
|  |       ); | ||||||
|  |       await tester.pumpAndSettle(); | ||||||
|  |  | ||||||
|  |       expect(mapStyle?.valueOrNull, "dark"); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     testWidgets("Return light theme style when system is light", | ||||||
|  |         (tester) async { | ||||||
|  |       AsyncValue<String>? mapStyle; | ||||||
|  |       await tester.pumpConsumerWidget( | ||||||
|  |         MapThemeOveride( | ||||||
|  |           mapBuilder: (AsyncValue<String> style) { | ||||||
|  |             mapStyle = style; | ||||||
|  |             return const Text("Mock"); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         overrides: overrides, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       tester.binding.platformDispatcher.platformBrightnessTestValue = | ||||||
|  |           Brightness.light; | ||||||
|  |       mapStateNotifier.state = mapState.copyWith( | ||||||
|  |         themeMode: ThemeMode.system, | ||||||
|  |         lightStyleFetched: const AsyncData("light"), | ||||||
|  |       ); | ||||||
|  |       await tester.pumpAndSettle(); | ||||||
|  |  | ||||||
|  |       expect(mapStyle?.valueOrNull, "light"); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     testWidgets("Switches style when system brightness changes", | ||||||
|  |         (tester) async { | ||||||
|  |       AsyncValue<String>? mapStyle; | ||||||
|  |       await tester.pumpConsumerWidget( | ||||||
|  |         MapThemeOveride( | ||||||
|  |           mapBuilder: (AsyncValue<String> style) { | ||||||
|  |             mapStyle = style; | ||||||
|  |             return const Text("Mock"); | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |         overrides: overrides, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       tester.binding.platformDispatcher.platformBrightnessTestValue = | ||||||
|  |           Brightness.light; | ||||||
|  |       mapStateNotifier.state = mapState.copyWith( | ||||||
|  |         themeMode: ThemeMode.system, | ||||||
|  |         lightStyleFetched: const AsyncData("light"), | ||||||
|  |         darkStyleFetched: const AsyncData("dark"), | ||||||
|  |       ); | ||||||
|  |       await tester.pumpAndSettle(); | ||||||
|  |       expect(mapStyle?.valueOrNull, "light"); | ||||||
|  |  | ||||||
|  |       tester.binding.platformDispatcher.platformBrightnessTestValue = | ||||||
|  |           Brightness.dark; | ||||||
|  |       await tester.pumpAndSettle(); | ||||||
|  |       expect(mapStyle?.valueOrNull, "dark"); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||||
| import 'package:mocktail/mocktail.dart'; | import 'package:mocktail/mocktail.dart'; | ||||||
|  |  | ||||||
| class AppSettingsServiceMock extends Mock implements AppSettingsService {} | class MockAppSettingsService extends Mock implements AppSettingsService {} | ||||||
|   | |||||||
							
								
								
									
										41
									
								
								mobile/test/modules/utils/debouncer_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								mobile/test/modules/utils/debouncer_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | import 'package:flutter_test/flutter_test.dart'; | ||||||
|  | import 'package:immich_mobile/utils/debounce.dart'; | ||||||
|  |  | ||||||
|  | class _Counter { | ||||||
|  |   int _count = 0; | ||||||
|  |   _Counter(); | ||||||
|  |  | ||||||
|  |   int get count => _count; | ||||||
|  |   void increment() => _count = _count + 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void main() { | ||||||
|  |   test('Executes the method after the interval', () async { | ||||||
|  |     var counter = _Counter(); | ||||||
|  |     final debouncer = Debouncer(interval: const Duration(milliseconds: 300)); | ||||||
|  |     debouncer.run(() => counter.increment()); | ||||||
|  |     expect(counter.count, 0); | ||||||
|  |     await Future.delayed(const Duration(milliseconds: 300)); | ||||||
|  |     expect(counter.count, 1); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test('Executes the method immediately if zero interval', () async { | ||||||
|  |     var counter = _Counter(); | ||||||
|  |     final debouncer = Debouncer(interval: const Duration(milliseconds: 0)); | ||||||
|  |     debouncer.run(() => counter.increment()); | ||||||
|  |     // Even though it is supposed to be executed immediately, it is added to the async queue and so | ||||||
|  |     // we need this delay to make sure the actual debounced method is called | ||||||
|  |     await Future.delayed(const Duration(milliseconds: 0)); | ||||||
|  |     expect(counter.count, 1); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test('Delayes method execution after all the calls are completed', () async { | ||||||
|  |     var counter = _Counter(); | ||||||
|  |     final debouncer = Debouncer(interval: const Duration(milliseconds: 100)); | ||||||
|  |     debouncer.run(() => counter.increment()); | ||||||
|  |     debouncer.run(() => counter.increment()); | ||||||
|  |     debouncer.run(() => counter.increment()); | ||||||
|  |     await Future.delayed(const Duration(milliseconds: 300)); | ||||||
|  |     expect(counter.count, 1); | ||||||
|  |   }); | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								mobile/test/modules/utils/throttler_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								mobile/test/modules/utils/throttler_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_test/flutter_test.dart'; | ||||||
|  | import 'package:immich_mobile/utils/throttle.dart'; | ||||||
|  |  | ||||||
|  | class _Counter { | ||||||
|  |   int _count = 0; | ||||||
|  |   _Counter(); | ||||||
|  |  | ||||||
|  |   int get count => _count; | ||||||
|  |   void increment() { | ||||||
|  |     debugPrint("Counter inside increment: $count"); | ||||||
|  |     _count = _count + 1; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void main() { | ||||||
|  |   test('Executes the method immediately if no calls received previously', | ||||||
|  |       () async { | ||||||
|  |     var counter = _Counter(); | ||||||
|  |     final throttler = Throttler(interval: const Duration(milliseconds: 300)); | ||||||
|  |     throttler.run(() => counter.increment()); | ||||||
|  |     expect(counter.count, 1); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test('Does not execute calls before throttle interval', () async { | ||||||
|  |     var counter = _Counter(); | ||||||
|  |     final throttler = Throttler(interval: const Duration(milliseconds: 100)); | ||||||
|  |     throttler.run(() => counter.increment()); | ||||||
|  |     throttler.run(() => counter.increment()); | ||||||
|  |     throttler.run(() => counter.increment()); | ||||||
|  |     throttler.run(() => counter.increment()); | ||||||
|  |     throttler.run(() => counter.increment()); | ||||||
|  |     await Future.delayed(const Duration(seconds: 1)); | ||||||
|  |     expect(counter.count, 1); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test('Executes the method if received in intervals', () async { | ||||||
|  |     var counter = _Counter(); | ||||||
|  |     final throttler = Throttler(interval: const Duration(milliseconds: 100)); | ||||||
|  |     for (final _ in Iterable<int>.generate(10)) { | ||||||
|  |       throttler.run(() => counter.increment()); | ||||||
|  |       await Future.delayed(const Duration(milliseconds: 50)); | ||||||
|  |     } | ||||||
|  |     await Future.delayed(const Duration(seconds: 1)); | ||||||
|  |     expect(counter.count, 5); | ||||||
|  |   }); | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user