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_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_settings_dark_mode": "Dark mode", | ||||
|   "map_settings_theme_settings": "Map Theme", | ||||
|   "map_settings_date_range_option_all": "All", | ||||
|   "map_settings_date_range_option_day": "Past 24 hours", | ||||
|   "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 | ||||
|   - isar_flutter_libs (1.0.0): | ||||
|     - Flutter | ||||
|   - MapLibre (5.14.0-pre3) | ||||
|   - maplibre_gl (0.0.1): | ||||
|     - Flutter | ||||
|     - MapLibre (= 5.14.0-pre3) | ||||
|   - package_info_plus (0.4.5): | ||||
|     - Flutter | ||||
|   - path_provider_foundation (0.0.1): | ||||
| @@ -71,6 +75,7 @@ DEPENDENCIES: | ||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||
|   - integration_test (from `.symlinks/plugins/integration_test/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`) | ||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | ||||
|   - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) | ||||
| @@ -86,6 +91,7 @@ DEPENDENCIES: | ||||
| SPEC REPOS: | ||||
|   trunk: | ||||
|     - FMDB | ||||
|     - MapLibre | ||||
|     - ReachabilitySwift | ||||
|     - SAMKeychain | ||||
|     - Toast | ||||
| @@ -115,6 +121,8 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/integration_test/ios" | ||||
|   isar_flutter_libs: | ||||
|     :path: ".symlinks/plugins/isar_flutter_libs/ios" | ||||
|   maplibre_gl: | ||||
|     :path: ".symlinks/plugins/maplibre_gl/ios" | ||||
|   package_info_plus: | ||||
|     :path: ".symlinks/plugins/package_info_plus/ios" | ||||
|   path_provider_foundation: | ||||
| @@ -152,6 +160,8 @@ SPEC CHECKSUMS: | ||||
|   image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 | ||||
|   integration_test: 13825b8a9334a850581300559b8839134b124670 | ||||
|   isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 | ||||
|   MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef | ||||
|   maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 | ||||
|   package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 | ||||
|   path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 | ||||
|   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 | ||||
|   | ||||
| @@ -96,3 +96,9 @@ extension AssetListExtension on Iterable<Asset> { | ||||
|     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 | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$currentAssetHash() => r'018d9f936991c48f06c11bf7e72130bba25806e2'; | ||||
| String _$currentAssetHash() => r'2def10ea594152c984ae2974d687ab6856d7bdd0'; | ||||
| 
 | ||||
| /// See also [CurrentAsset]. | ||||
| @ProviderFor(CurrentAsset) | ||||
|   | ||||
| @@ -2,19 +2,18 @@ import 'dart:io'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_map/flutter_map.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/extensions/asset_extensions.dart'; | ||||
| import 'package:immich_mobile/extensions/build_context_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/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/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | ||||
| import 'package:immich_mobile/utils/selection_handlers.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| import 'package:immich_mobile/utils/bytes_units.dart'; | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
| import 'package:url_launcher/url_launcher.dart'; | ||||
|  | ||||
| class ExifBottomSheet extends HookConsumerWidget { | ||||
| @@ -92,26 +91,14 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|         child: LayoutBuilder( | ||||
|           builder: (context, constraints) { | ||||
|             return MapThumbnail( | ||||
|               showAttribution: false, | ||||
|               coords: LatLng( | ||||
|               centre: LatLng( | ||||
|                 exifInfo?.latitude ?? 0, | ||||
|                 exifInfo?.longitude ?? 0, | ||||
|               ), | ||||
|               height: 150, | ||||
|               width: constraints.maxWidth, | ||||
|               zoom: 12.0, | ||||
|               markers: [ | ||||
|                 Marker( | ||||
|                   anchorPos: AnchorPos.align(AnchorAlign.top), | ||||
|                   point: LatLng( | ||||
|                     exifInfo?.latitude ?? 0, | ||||
|                     exifInfo?.longitude ?? 0, | ||||
|                   ), | ||||
|                   builder: (ctx) => const Image( | ||||
|                     image: AssetImage('assets/location-pin.png'), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|               assetMarkerRemoteId: asset.remoteId, | ||||
|               onTap: (tapPosition, latLong) async { | ||||
|                 Uri? uri = await createCoordinatesUri(); | ||||
|  | ||||
|   | ||||
| @@ -27,7 +27,7 @@ class ImmichAssetGrid extends HookConsumerWidget { | ||||
|   final bool canDeselect; | ||||
|   final bool? dynamicLayout; | ||||
|   final bool showMultiSelectIndicator; | ||||
|   final void Function(ItemPosition start, ItemPosition end)? | ||||
|   final void Function(Iterable<ItemPosition> itemPositions)? | ||||
|       visibleItemsListener; | ||||
|   final Widget? topWidget; | ||||
|   final bool shrinkWrap; | ||||
| @@ -89,8 +89,10 @@ class ImmichAssetGrid extends HookConsumerWidget { | ||||
|             }; | ||||
|  | ||||
|             scale.onUpdate = (details) { | ||||
|               scaleFactor.value = | ||||
|                   max(min(5.0, baseScaleFactor.value * details.scale), 1.0); | ||||
|               scaleFactor.value = max( | ||||
|                 min(5.0, baseScaleFactor.value * details.scale), | ||||
|                 1.0, | ||||
|               ); | ||||
|               if (7 - scaleFactor.value.toInt() != perRow.value) { | ||||
|                 perRow.value = 7 - scaleFactor.value.toInt(); | ||||
|               } | ||||
|   | ||||
| @@ -32,7 +32,7 @@ class ImmichAssetGridView extends StatefulWidget { | ||||
|   final bool canDeselect; | ||||
|   final bool dynamicLayout; | ||||
|   final bool showMultiSelectIndicator; | ||||
|   final void Function(ItemPosition start, ItemPosition end)? | ||||
|   final void Function(Iterable<ItemPosition> itemPositions)? | ||||
|       visibleItemsListener; | ||||
|   final Widget? topWidget; | ||||
|   final int heroOffset; | ||||
| @@ -421,15 +421,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | ||||
|  | ||||
|   void _positionListener() { | ||||
|     final values = _itemPositionsListener.itemPositions.value; | ||||
|     final start = values.firstOrNull; | ||||
|     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); | ||||
|       } | ||||
|     } | ||||
|     widget.visibleItemsListener?.call(values); | ||||
|   } | ||||
|  | ||||
|   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 { | ||||
|   final bool isDarkTheme; | ||||
|   final ThemeMode themeMode; | ||||
|   final bool showFavoriteOnly; | ||||
|   final bool includeArchived; | ||||
|   final int relativeTime; | ||||
|   final Style? mapStyle; | ||||
|   final bool isLoading; | ||||
|   final bool shouldRefetchMarkers; | ||||
|   final AsyncValue<String> lightStyleFetched; | ||||
|   final AsyncValue<String> darkStyleFetched; | ||||
|  | ||||
|   MapState({ | ||||
|     this.isDarkTheme = false, | ||||
|     this.themeMode = ThemeMode.system, | ||||
|     this.showFavoriteOnly = false, | ||||
|     this.includeArchived = false, | ||||
|     this.relativeTime = 0, | ||||
|     this.mapStyle, | ||||
|     this.isLoading = false, | ||||
|     this.shouldRefetchMarkers = false, | ||||
|     this.lightStyleFetched = const AsyncLoading(), | ||||
|     this.darkStyleFetched = const AsyncLoading(), | ||||
|   }); | ||||
|  | ||||
|   MapState copyWith({ | ||||
|     bool? isDarkTheme, | ||||
|     ThemeMode? themeMode, | ||||
|     bool? showFavoriteOnly, | ||||
|     bool? includeArchived, | ||||
|     int? relativeTime, | ||||
|     Style? mapStyle, | ||||
|     bool? isLoading, | ||||
|     bool? shouldRefetchMarkers, | ||||
|     AsyncValue<String>? lightStyleFetched, | ||||
|     AsyncValue<String>? darkStyleFetched, | ||||
|   }) { | ||||
|     return MapState( | ||||
|       isDarkTheme: isDarkTheme ?? this.isDarkTheme, | ||||
|       themeMode: themeMode ?? this.themeMode, | ||||
|       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, | ||||
|       includeArchived: includeArchived ?? this.includeArchived, | ||||
|       relativeTime: relativeTime ?? this.relativeTime, | ||||
|       mapStyle: mapStyle ?? this.mapStyle, | ||||
|       isLoading: isLoading ?? this.isLoading, | ||||
|       shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers, | ||||
|       lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched, | ||||
|       darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   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 | ||||
|   bool operator ==(Object other) { | ||||
|   bool operator ==(covariant MapState other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is MapState && | ||||
|         other.isDarkTheme == isDarkTheme && | ||||
|     return other.themeMode == themeMode && | ||||
|         other.showFavoriteOnly == showFavoriteOnly && | ||||
|         other.relativeTime == relativeTime && | ||||
|         other.includeArchived == includeArchived && | ||||
|         other.mapStyle == mapStyle && | ||||
|         other.isLoading == isLoading; | ||||
|         other.relativeTime == relativeTime && | ||||
|         other.shouldRefetchMarkers == shouldRefetchMarkers && | ||||
|         other.lightStyleFetched == lightStyleFetched && | ||||
|         other.darkStyleFetched == darkStyleFetched; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     return isDarkTheme.hashCode ^ | ||||
|     return themeMode.hashCode ^ | ||||
|         showFavoriteOnly.hashCode ^ | ||||
|         relativeTime.hashCode ^ | ||||
|         includeArchived.hashCode ^ | ||||
|         mapStyle.hashCode ^ | ||||
|         isLoading.hashCode; | ||||
|         relativeTime.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/services/map.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||
|  | ||||
| final mapMarkersProvider = | ||||
|     FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async { | ||||
| part 'map_marker.provider.g.dart'; | ||||
|  | ||||
| @riverpod | ||||
| Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async { | ||||
|   final service = ref.read(mapServiceProvider); | ||||
|   final mapState = ref.read(mapStateNotifier); | ||||
|   final mapState = ref.read(mapStateNotifierProvider); | ||||
|   DateTime? fileCreatedAfter; | ||||
|   bool? isFavorite; | ||||
|   bool? isIncludeArchived; | ||||
| @@ -31,34 +32,5 @@ final mapMarkersProvider = | ||||
|     fileCreatedAfter: fileCreatedAfter, | ||||
|   ); | ||||
|  | ||||
|   final assetMarkerData = await Future.wait( | ||||
|     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; | ||||
|   } | ||||
|   return markers.toList(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										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 'package:flutter/foundation.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/settings/providers/app_settings.provider.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/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: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> { | ||||
|   MapStateNotifier(this._appSettingsProvider, this._apiService) | ||||
|       : super( | ||||
|           MapState( | ||||
|             isDarkTheme: _appSettingsProvider | ||||
|                 .getSetting<bool>(AppSettingsEnum.mapThemeMode), | ||||
|             showFavoriteOnly: _appSettingsProvider | ||||
| part 'map_state.provider.g.dart'; | ||||
|  | ||||
| @Riverpod(keepAlive: true) | ||||
| class MapStateNotifier extends _$MapStateNotifier { | ||||
|   final _log = Logger("MapStateNotifier"); | ||||
|  | ||||
|   @override | ||||
|   MapState build() { | ||||
|     final appSettingsProvider = ref.read(appSettingsServiceProvider); | ||||
|  | ||||
|     // Fetch and save the Style JSONs | ||||
|     loadStyles(); | ||||
|     return MapState( | ||||
|       themeMode: ThemeMode.values[ | ||||
|           appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)], | ||||
|       showFavoriteOnly: appSettingsProvider | ||||
|           .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly), | ||||
|             includeArchived: _appSettingsProvider | ||||
|       includeArchived: appSettingsProvider | ||||
|           .getSetting<bool>(AppSettingsEnum.mapIncludeArchived), | ||||
|             relativeTime: _appSettingsProvider | ||||
|                 .getSetting<int>(AppSettingsEnum.mapRelativeDate), | ||||
|             isLoading: true, | ||||
|           ), | ||||
|         ) { | ||||
|     _fetchStyleFromServer( | ||||
|       _appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode), | ||||
|       relativeTime: | ||||
|           appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   final AppSettingsService _appSettingsProvider; | ||||
|   final ApiService _apiService; | ||||
|   final Logger _log = Logger("MapStateNotifier"); | ||||
|   void loadStyles() async { | ||||
|     final documents = (await getApplicationDocumentsDirectory()).path; | ||||
|  | ||||
|   bool get isRaster => | ||||
|       state.mapStyle != null && state.mapStyle!.rasterTileProvider != null; | ||||
|     // Set to loading | ||||
|     state = state.copyWith(lightStyleFetched: const AsyncLoading()); | ||||
|  | ||||
|   double get maxZoom => | ||||
|       (isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 18) | ||||
|           .toDouble(); | ||||
|     // Fetch and save light theme | ||||
|     final lightResponse = await ref | ||||
|         .read(apiServiceProvider) | ||||
|         .systemConfigApi | ||||
|         .getMapStyleWithHttpInfo(MapTheme.light); | ||||
|  | ||||
|   void switchTheme(bool isDarkTheme) { | ||||
|     _updateThemeMode(isDarkTheme); | ||||
|     _fetchStyleFromServer(isDarkTheme); | ||||
|   } | ||||
|  | ||||
|   void _updateThemeMode(bool isDarkTheme) { | ||||
|     _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; | ||||
|     } | ||||
|     final styleJson = await compute(jsonDecode, styleJsonString); | ||||
|     if (styleJson is! Map<String, dynamic>) { | ||||
|       _log.severe('Style JSON from server is invalid'); | ||||
|       return; | ||||
|     } | ||||
|     final styleReader = StyleReader(uri: ''); | ||||
|     Style? style; | ||||
|     try { | ||||
|       style = await styleReader.readFromMap(styleJson); | ||||
|     } finally { | ||||
|       // Consume all error | ||||
|     } | ||||
|     if (lightResponse.statusCode >= HttpStatus.badRequest) { | ||||
|       state = state.copyWith( | ||||
|       mapStyle: style, | ||||
|       isLoading: false, | ||||
|         lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), | ||||
|       ); | ||||
|       _log.severe( | ||||
|         "Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}", | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     final lightJSON = lightResponse.body; | ||||
|     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; | ||||
|     } | ||||
|  | ||||
|     final darkJSON = darkResponse.body; | ||||
|     final darkFile = await File("$documents/map-style-dark.json") | ||||
|         .writeAsString(darkJSON, flush: true); | ||||
|  | ||||
|     // Update state with path | ||||
|     state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); | ||||
|   } | ||||
|  | ||||
|   void switchTheme(ThemeMode mode) { | ||||
|     ref.read(appSettingsServiceProvider).setSetting( | ||||
|           AppSettingsEnum.mapThemeMode, | ||||
|           mode.index, | ||||
|         ); | ||||
|     state = state.copyWith(themeMode: mode); | ||||
|   } | ||||
|  | ||||
|   void switchFavoriteOnly(bool isFavoriteOnly) { | ||||
|     _appSettingsProvider.setSetting( | ||||
|     ref.read(appSettingsServiceProvider).setSetting( | ||||
|           AppSettingsEnum.mapShowFavoriteOnly, | ||||
|           isFavoriteOnly, | ||||
|         ); | ||||
|     state = state.copyWith(showFavoriteOnly: isFavoriteOnly); | ||||
|     state = state.copyWith( | ||||
|       showFavoriteOnly: isFavoriteOnly, | ||||
|       shouldRefetchMarkers: true, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void setRefetchMarkers(bool shouldRefetch) { | ||||
|     state = state.copyWith(shouldRefetchMarkers: shouldRefetch); | ||||
|   } | ||||
|  | ||||
|   void switchIncludeArchived(bool isIncludeArchived) { | ||||
|     _appSettingsProvider.setSetting( | ||||
|     ref.read(appSettingsServiceProvider).setSetting( | ||||
|           AppSettingsEnum.mapIncludeArchived, | ||||
|           isIncludeArchived, | ||||
|         ); | ||||
|     state = state.copyWith(includeArchived: isIncludeArchived); | ||||
|     state = state.copyWith( | ||||
|       includeArchived: isIncludeArchived, | ||||
|       shouldRefetchMarkers: true, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void setRelativeTime(int relativeTime) { | ||||
|     _appSettingsProvider.setSetting( | ||||
|     ref.read(appSettingsServiceProvider).setSetting( | ||||
|           AppSettingsEnum.mapRelativeDate, | ||||
|           relativeTime, | ||||
|         ); | ||||
|     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(), | ||||
|     state = state.copyWith( | ||||
|       relativeTime: relativeTime, | ||||
|       shouldRefetchMarkers: true, | ||||
|     ); | ||||
|       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,33 +1,23 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/mixins/error_logger.mixin.dart'; | ||||
| import 'package:immich_mobile/modules/map/models/map_marker.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final mapServiceProvider = Provider( | ||||
|   (ref) => MapSerivce( | ||||
|     ref.read(apiServiceProvider), | ||||
|     ref.read(dbProvider), | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class MapSerivce { | ||||
| class MapSerivce with ErrorLoggerMixin { | ||||
|   final ApiService _apiService; | ||||
|   final Isar _db; | ||||
|   final _log = Logger("MapService"); | ||||
|   @override | ||||
|   final logger = Logger("MapService"); | ||||
|  | ||||
|   MapSerivce(this._apiService, this._db); | ||||
|   MapSerivce(this._apiService); | ||||
|  | ||||
|   Future<List<MapMarkerResponseDto>> getMapMarkers({ | ||||
|   Future<Iterable<MapMarker>> getMapMarkers({ | ||||
|     bool? isFavorite, | ||||
|     bool? withArchived, | ||||
|     DateTime? fileCreatedAfter, | ||||
|     DateTime? fileCreatedBefore, | ||||
|   }) async { | ||||
|     try { | ||||
|     return logError( | ||||
|       () async { | ||||
|         final markers = await _apiService.assetApi.getMapMarkers( | ||||
|           isFavorite: isFavorite, | ||||
|           isArchived: withArchived, | ||||
| @@ -35,28 +25,9 @@ class MapSerivce { | ||||
|           fileCreatedBefore: fileCreatedBefore, | ||||
|         ); | ||||
|  | ||||
|       return markers ?? []; | ||||
|     } catch (error, stack) { | ||||
|       _log.severe("Cannot get map markers ${error.toString()}", error, stack); | ||||
|       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 markers?.map(MapMarker.fromDto) ?? []; | ||||
|       }, | ||||
|       defaultValue: [], | ||||
|     ); | ||||
|       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,108 +1,179 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:math' as math; | ||||
|  | ||||
| import 'dart:math'; | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.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:geolocator/geolocator.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; | ||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||
| import 'package:immich_mobile/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_state.provider.dart'; | ||||
| import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart'; | ||||
| import 'package:immich_mobile/modules/map/ui/location_dialog.dart'; | ||||
| import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart'; | ||||
| import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart'; | ||||
| import 'package:immich_mobile/modules/map/utils/map_utils.dart'; | ||||
| import 'package:immich_mobile/modules/map/widgets/map_app_bar.dart'; | ||||
| import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart'; | ||||
| import 'package:immich_mobile/modules/map/widgets/map_bottom_sheet.dart'; | ||||
| import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart'; | ||||
| import 'package:immich_mobile/modules/map/widgets/positioned_asset_marker_icon.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/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/views/immich_loading_overlay.dart'; | ||||
| import 'package:immich_mobile/utils/debounce.dart'; | ||||
| import 'package:immich_mobile/extensions/flutter_map_extensions.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'; | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
|  | ||||
| class MapPage extends StatefulHookConsumerWidget { | ||||
| class MapPage extends HookConsumerWidget { | ||||
|   const MapPage({super.key}); | ||||
|  | ||||
|   @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> { | ||||
|   // Non-State variables | ||||
|   late final MapController mapController; | ||||
|   // Streams are used instead of callbacks to prevent unnecessary rebuilds on events | ||||
|   final StreamController mapPageEventSC = | ||||
|       StreamController<MapPageEventBase>.broadcast(); | ||||
|   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), | ||||
|     ); | ||||
|     // updates the markersInBounds value with the map markers that are visible in the current | ||||
|     // map camera bounds | ||||
|     Future<void> updateAssetsInBounds() async { | ||||
|       // Guard map not created | ||||
|       if (mapController.value == null) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|   @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(), | ||||
|       final bounds = await mapController.value!.getVisibleRegion(); | ||||
|       final inBounds = markers.value | ||||
|           .where( | ||||
|             (m) => | ||||
|                 bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)), | ||||
|           ) | ||||
|           .toList(); | ||||
|       // Notify bottom sheet to update asset grid only when there are new assets | ||||
|       if (markersInBounds.value.length != inBounds.length) { | ||||
|         bottomSheetStreamController.add( | ||||
|           MapAssetsInBoundsUpdated( | ||||
|             inBounds.map((e) => e.assetRemoteId).toList(), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|       markersInBounds.value = inBounds; | ||||
|     } | ||||
|     } finally { | ||||
|       // Consume all error | ||||
|  | ||||
|     // 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); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   void openAssetInViewer(Asset asset) { | ||||
|     Future<void> loadMarkers() async { | ||||
|       try { | ||||
|         isLoading.value = true; | ||||
|         markers.value = await ref.read(mapMarkersProvider.future); | ||||
|         assetsDebouncer.run(updateAssetsInBounds); | ||||
|         reloadLayers(); | ||||
|       } finally { | ||||
|         isLoading.value = false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect( | ||||
|       () { | ||||
|         loadMarkers(); | ||||
|         return null; | ||||
|       }, | ||||
|       [], | ||||
|     ); | ||||
|  | ||||
|     // Refetch markers when map state is changed | ||||
|     ref.listen(mapStateNotifierProvider, (_, current) { | ||||
|       if (current.shouldRefetchMarkers) { | ||||
|         markerDebouncer.run(() { | ||||
|           ref.invalidate(mapMarkersProvider); | ||||
|           // Reset marker | ||||
|           selectedMarker.value = null; | ||||
|           loadMarkers(); | ||||
|           ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false); | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // updates the selected markers position based on the current map camera | ||||
|     Future<void> updateAssetMarkerPosition( | ||||
|       MapMarker marker, { | ||||
|       bool shouldAnimate = true, | ||||
|     }) async { | ||||
|       final assetPoint = | ||||
|           await mapController.value!.toScreenLocation(marker.latLng); | ||||
|       selectedMarker.value = _AssetMarkerMeta( | ||||
|         point: assetPoint, | ||||
|         marker: marker, | ||||
|         shouldAnimate: shouldAnimate, | ||||
|       ); | ||||
|       (assetPoint, marker, shouldAnimate); | ||||
|     } | ||||
|  | ||||
|     // finds the nearest asset marker from the tap point and store it as the selectedMarker | ||||
|     Future<void> onMarkerClicked(Point<double> point, LatLng coords) async { | ||||
|       // Guard map not created | ||||
|       if (mapController.value == null) { | ||||
|         return; | ||||
|       } | ||||
|       final latlngBound = | ||||
|           await mapController.value!.getBoundsFromPoint(point, 50); | ||||
|       final marker = markersInBounds.value.firstWhereOrNull( | ||||
|         (m) => | ||||
|             latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)), | ||||
|       ); | ||||
|  | ||||
|       if (marker != null) { | ||||
|         updateAssetMarkerPosition(marker); | ||||
|       } else { | ||||
|         // If no asset was previously selected and no new asset is available, close the bottom sheet | ||||
|         if (selectedMarker.value == null) { | ||||
|           bottomSheetStreamController.add(MapCloseBottomSheet()); | ||||
|         } | ||||
|         selectedMarker.value = null; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void onMapCreated(MaplibreMapController controller) async { | ||||
|       mapController.value = controller; | ||||
|       controller.addListener(() { | ||||
|         if (controller.isCameraMoving && selectedMarker.value != null) { | ||||
|           updateAssetMarkerPosition( | ||||
|             selectedMarker.value!.marker, | ||||
|             shouldAnimate: false, | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     Future<void> onMarkerTapped() async { | ||||
|       final assetId = selectedMarker.value?.marker.assetRemoteId; | ||||
|       if (assetId == null) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       final asset = await ref.read(dbProvider).assets.getByRemoteId(assetId); | ||||
|       if (asset == null) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       context.pushRoute( | ||||
|         GalleryViewerRoute( | ||||
|           initialIndex: 0, | ||||
| @@ -113,138 +184,42 @@ class MapPageState extends ConsumerState<MapPage> { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final log = Logger("MapService"); | ||||
|     final isDarkTheme = | ||||
|         ref.watch(mapStateNotifier.select((state) => state.isDarkTheme)); | ||||
|     final ValueNotifier<Set<AssetMarkerData>> mapMarkerData = | ||||
|         useState(<AssetMarkerData>{}); | ||||
|     final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null); | ||||
|     final selectionEnabledHook = useState(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); | ||||
|     /// BOTTOM SHEET CALLBACKS | ||||
|  | ||||
|     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; | ||||
|             }, | ||||
|           ); | ||||
|     Future<void> onMapMoved() async { | ||||
|       assetsDebouncer.run(updateAssetsInBounds); | ||||
|     } | ||||
|  | ||||
|     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 onBottomSheetScrolled(String assetRemoteId) { | ||||
|       final assetMarker = markersInBounds.value | ||||
|           .firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId); | ||||
|       if (assetMarker != null) { | ||||
|         updateAssetMarkerPosition(assetMarker); | ||||
|       } | ||||
|     } | ||||
|     }); | ||||
|  | ||||
|     void onZoomToAssetEvent(Asset? assetInBottomSheet) { | ||||
|       if (assetInBottomSheet != null) { | ||||
|         final mapMarker = mapMarkerData.value | ||||
|             .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id); | ||||
|         if (mapMarker != null) { | ||||
|           LatLng? newCenter = mapController.centerBoundsWithPadding( | ||||
|             mapMarker.point, | ||||
|             const Offset(0, -120), | ||||
|             zoomLevel: zoomLevel, | ||||
|     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), | ||||
|         ); | ||||
|           if (newCenter != null) { | ||||
|             forceAssetUpdate = true; | ||||
|             mapController.move(newCenter, zoomLevel); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void onZoomToLocation() async { | ||||
|       try { | ||||
|         bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); | ||||
|         if (!serviceEnabled) { | ||||
|           showDialog( | ||||
|             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) { | ||||
|       final location = await MapUtils.checkPermAndGetLocation(context); | ||||
|       if (location.$2 != null) { | ||||
|         if (location.$2 == LocationPermission.unableToDetermine && | ||||
|             context.mounted) { | ||||
|           ImmichToast.show( | ||||
|             context: context, | ||||
|             gravity: ToastGravity.BOTTOM, | ||||
| @@ -252,253 +227,180 @@ class MapPageState extends ConsumerState<MapPage> { | ||||
|             msg: "map_cannot_get_user_location".tr(), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|     void handleBottomSheetEvents(dynamic event) { | ||||
|       if (event is MapPageBottomSheetScrolled) { | ||||
|         final assetInBottomSheet = event.asset; | ||||
|         if (assetInBottomSheet != null) { | ||||
|           final mapMarker = mapMarkerData.value | ||||
|               .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id); | ||||
|           closestAssetMarker.value = mapMarker; | ||||
|           if (mapMarker != null && mapController.zoom >= 5) { | ||||
|             LatLng? newCenter = mapController.centerBoundsWithPadding( | ||||
|               mapMarker.point, | ||||
|               const Offset(0, -120), | ||||
|             ); | ||||
|             if (newCenter != null) { | ||||
|               mapController.move( | ||||
|                 newCenter, | ||||
|                 mapController.zoom, | ||||
|       if (mapController.value != null && location.$1 != null) { | ||||
|         mapController.value!.animateCamera( | ||||
|           CameraUpdate.newLatLngZoom( | ||||
|             LatLng(location.$1!.latitude, location.$1!.longitude), | ||||
|             mapZoomToAssetLevel, | ||||
|           ), | ||||
|           duration: const Duration(milliseconds: 800), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|         } | ||||
|       } else if (event is MapPageZoomToAsset) { | ||||
|         onZoomToAssetEvent(event.asset); | ||||
|       } else if (event is MapPageZoomToLocation) { | ||||
|         onZoomToLocation(); | ||||
|       } | ||||
|  | ||||
|     void onAssetsSelected(bool selected, Set<Asset> selection) { | ||||
|       selectedAssets.value = selected ? selection : {}; | ||||
|     } | ||||
|  | ||||
|     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) { | ||||
|       if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) { | ||||
|         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() { | ||||
|       handleShareAssets(ref, context, selectedAssets.value.toList()); | ||||
|       selectionEnabledHook.value = false; | ||||
|     } | ||||
|  | ||||
|     void onFavoriteAsset() async { | ||||
|       showLoadingIndicator.value = true; | ||||
|       try { | ||||
|         await handleFavoriteAssets(ref, context, selectedAssets.value.toList()); | ||||
|       } finally { | ||||
|         showLoadingIndicator.value = false; | ||||
|         selectionEnabledHook.value = false; | ||||
|         refetchMarkers.value = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void onArchiveAsset() async { | ||||
|       showLoadingIndicator.value = true; | ||||
|       try { | ||||
|         await handleArchiveAssets(ref, context, selectedAssets.value.toList()); | ||||
|       } finally { | ||||
|         showLoadingIndicator.value = false; | ||||
|         selectionEnabledHook.value = false; | ||||
|         refetchMarkers.value = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void selectionListener(bool isMultiSelect, Set<Asset> selection) { | ||||
|       selectionEnabledHook.value = isMultiSelect; | ||||
|       selectedAssets.value = selection; | ||||
|     } | ||||
|  | ||||
|     final markerLayer = MarkerLayer( | ||||
|       markers: [ | ||||
|         if (closestAssetMarker.value != null) | ||||
|           AssetMarker( | ||||
|             remoteId: closestAssetMarker.value!.asset.remoteId!, | ||||
|             anchorPos: AnchorPos.align(AnchorAlign.top), | ||||
|             point: closestAssetMarker.value!.point, | ||||
|             width: 100, | ||||
|             height: 100, | ||||
|             builder: (ctx) => GestureDetector( | ||||
|               onTap: () => openAssetInViewer(closestAssetMarker.value!.asset), | ||||
|               child: AssetMarkerIcon( | ||||
|                 key: Key(closestAssetMarker.value!.asset.remoteId!), | ||||
|                 isDarkTheme: isDarkTheme, | ||||
|                 id: closestAssetMarker.value!.asset.remoteId!, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|       ], | ||||
|     ); | ||||
|  | ||||
|     final heatMapLayer = mapMarkerData.value.isNotEmpty | ||||
|         ? HeatMapLayer( | ||||
|             heatMapDataSource: InMemoryHeatMapDataSource( | ||||
|               data: mapMarkerData.value | ||||
|                   .map( | ||||
|                     (e) => WeightedLatLng( | ||||
|                       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, | ||||
|           ), | ||||
|     return MapThemeOveride( | ||||
|       mapBuilder: (style) => context.isMobile | ||||
|           // Single-column | ||||
|           ? Scaffold( | ||||
|               extendBodyBehindAppBar: true, | ||||
|               appBar: MapAppBar(selectedAssets: selectedAssets), | ||||
|               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); | ||||
|                     }, | ||||
|                   _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: [ | ||||
|                     ref.read(mapStateNotifier.notifier).getTileLayer(), | ||||
|                     heatMapLayer, | ||||
|                     markerLayer, | ||||
|                   ], | ||||
|                 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, | ||||
|                         ), | ||||
|               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(), | ||||
|                           right: 0, | ||||
|                           bottom: 30, | ||||
|                           child: ElevatedButton( | ||||
|                             onPressed: onZoomToLocation, | ||||
|                             style: ElevatedButton.styleFrom( | ||||
|                               shape: const CircleBorder(), | ||||
|                             ), | ||||
|                             child: const Icon(Icons.my_location), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|                 Expanded( | ||||
|                   child: LayoutBuilder( | ||||
|                     builder: (ctx, constraints) => MapAssetGrid( | ||||
|                       controller: scrollController, | ||||
|                       mapEventStream: bottomSheetStreamController.stream, | ||||
|                       onGridAssetChanged: onBottomSheetScrolled, | ||||
|                       onZoomToAsset: onZoomToAsset, | ||||
|                       onAssetsSelected: onAssetsSelected, | ||||
|                       selectedAssets: selectedAssets, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AssetMarker extends Marker { | ||||
|   String remoteId; | ||||
| class _AssetMarkerMeta { | ||||
|   final Point<num> point; | ||||
|   final MapMarker marker; | ||||
|   final bool shouldAnimate; | ||||
|  | ||||
|   AssetMarker({ | ||||
|     super.key, | ||||
|     required this.remoteId, | ||||
|     super.anchorPos, | ||||
|     required super.point, | ||||
|     super.width = 100.0, | ||||
|     super.height = 100.0, | ||||
|     required super.builder, | ||||
|   const _AssetMarkerMeta({ | ||||
|     required this.point, | ||||
|     required this.marker, | ||||
|     required this.shouldAnimate, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   String toString() => | ||||
|       '_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)'; | ||||
| } | ||||
|  | ||||
| class _MapWithMarker extends StatelessWidget { | ||||
|   final AsyncValue<String> style; | ||||
|   final MapCreatedCallback onMapCreated; | ||||
|   final OnCameraIdleCallback onMapMoved; | ||||
|   final OnMapClickCallback onMapClicked; | ||||
|   final OnStyleLoadedCallback onStyleLoaded; | ||||
|   final Function()? onMarkerTapped; | ||||
|   final ValueNotifier<_AssetMarkerMeta?> selectedMarker; | ||||
|  | ||||
|   const _MapWithMarker({ | ||||
|     required this.style, | ||||
|     required this.onMapCreated, | ||||
|     required this.onMapMoved, | ||||
|     required this.onMapClicked, | ||||
|     required this.onStyleLoaded, | ||||
|     required this.selectedMarker, | ||||
|     this.onMarkerTapped, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return LayoutBuilder( | ||||
|       builder: (ctx, constraints) => SizedBox( | ||||
|         height: constraints.maxHeight, | ||||
|         width: constraints.maxWidth, | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             style.widgetWhen( | ||||
|               onData: (style) => MaplibreMap( | ||||
|                 initialCameraPosition: | ||||
|                     const CameraPosition(target: LatLng(0, 0)), | ||||
|                 styleString: style, | ||||
|                 // This is needed to update the selectedMarker's position on map camera updates | ||||
|                 // The changes are notified through the mapController ValueListener which is added in [onMapCreated] | ||||
|                 trackCameraPosition: true, | ||||
|                 onMapCreated: onMapCreated, | ||||
|                 onCameraIdle: onMapMoved, | ||||
|                 onMapClick: onMapClicked, | ||||
|                 onStyleLoadedCallback: onStyleLoaded, | ||||
|                 tiltGesturesEnabled: false, | ||||
|                 dragEnabled: false, | ||||
|                 myLocationEnabled: false, | ||||
|                 attributionButtonPosition: AttributionButtonPosition.TopRight, | ||||
|               ), | ||||
|             ), | ||||
|             ValueListenableBuilder( | ||||
|               valueListenable: selectedMarker, | ||||
|               builder: (ctx, value, _) => value != null | ||||
|                   ? PositionedAssetMarkerIcon( | ||||
|                       point: value.point, | ||||
|                       assetRemoteId: value.marker.assetRemoteId, | ||||
|                       durationInMilliseconds: value.shouldAnimate ? 100 : 0, | ||||
|                       onTap: onMarkerTapped, | ||||
|                     ) | ||||
|                   : const SizedBox.shrink(), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										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:flutter/material.dart'; | ||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| 
 | ||||
| class AssetMarkerIcon extends StatelessWidget { | ||||
|   const AssetMarkerIcon({ | ||||
| class PositionedAssetMarkerIcon extends StatelessWidget { | ||||
|   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, | ||||
|   }); | ||||
| 
 | ||||
|   @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, | ||||
|     this.isDarkTheme = false, | ||||
|     super.key, | ||||
|   }); | ||||
| 
 | ||||
|   final String id; | ||||
|   final bool isDarkTheme; | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -26,8 +66,8 @@ class AssetMarkerIcon extends StatelessWidget { | ||||
|               left: constraints.maxWidth * 0.5, | ||||
|               child: CustomPaint( | ||||
|                 painter: _PinPainter( | ||||
|                   primaryColor: isDarkTheme ? Colors.white : Colors.black, | ||||
|                   secondaryColor: isDarkTheme ? Colors.black : Colors.white, | ||||
|                   primaryColor: context.colorScheme.onSurface, | ||||
|                   secondaryColor: context.colorScheme.surface, | ||||
|                   primaryRadius: constraints.maxHeight * 0.06, | ||||
|                   secondaryRadius: constraints.maxHeight * 0.038, | ||||
|                 ), | ||||
| @@ -42,7 +82,7 @@ class AssetMarkerIcon extends StatelessWidget { | ||||
|               left: constraints.maxWidth * 0.17, | ||||
|               child: CircleAvatar( | ||||
|                 radius: constraints.maxHeight * 0.40, | ||||
|                 backgroundColor: isDarkTheme ? Colors.white : Colors.black, | ||||
|                 backgroundColor: context.colorScheme.onSurface, | ||||
|                 child: CircleAvatar( | ||||
|                   radius: constraints.maxHeight * 0.37, | ||||
|                   backgroundImage: CachedNetworkImageProvider( | ||||
| @@ -72,8 +112,8 @@ class _PinPainter extends CustomPainter { | ||||
|   final double secondaryRadius; | ||||
| 
 | ||||
|   _PinPainter({ | ||||
|     this.primaryColor = Colors.black, | ||||
|     this.secondaryColor = Colors.white, | ||||
|     required this.primaryColor, | ||||
|     required this.secondaryColor, | ||||
|     required this.primaryRadius, | ||||
|     required this.secondaryRadius, | ||||
|   }); | ||||
| @@ -6,7 +6,7 @@ part of 'person.service.dart'; | ||||
| // RiverpodGenerator | ||||
| // ************************************************************************** | ||||
| 
 | ||||
| String _$personServiceHash() => r'cde0a9c029d16ddde2adcd58ae8c863bf8cc1fed'; | ||||
| String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798'; | ||||
| 
 | ||||
| /// See also [personService]. | ||||
| @ProviderFor(personService) | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/ui/map_thumbnail.dart'; | ||||
| import 'package:immich_mobile/modules/map/widgets/map_thumbnail.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/routing/router.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 { | ||||
|   final bool isMapEnabled; | ||||
| @@ -38,14 +37,13 @@ class CuratedPlacesRow extends CuratedRow { | ||||
|                 padding: const EdgeInsets.only(right: 10.0), | ||||
|                 child: MapThumbnail( | ||||
|                   zoom: 2, | ||||
|                   coords: LatLng( | ||||
|                   centre: const LatLng( | ||||
|                     47, | ||||
|                     5, | ||||
|                   ), | ||||
|                   height: imageSize, | ||||
|                   width: imageSize, | ||||
|                   showAttribution: false, | ||||
|                   isDarkTheme: context.isDarkTheme, | ||||
|                 ), | ||||
|               ), | ||||
|               Padding( | ||||
|   | ||||
| @@ -46,7 +46,7 @@ enum AppSettingsEnum<T> { | ||||
|   advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), | ||||
|   logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 | ||||
|   preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), | ||||
|   mapThemeMode<bool>(StoreKey.mapThemeMode, null, false), | ||||
|   mapThemeMode<int>(StoreKey.mapThemeMode, null, 0), | ||||
|   mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false), | ||||
|   mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false), | ||||
|   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/library_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/memories/models/memory.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/tab_controller_page.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart' hide LatLng; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
|  | ||||
| part 'router.gr.dart'; | ||||
|  | ||||
|   | ||||
| @@ -1593,7 +1593,7 @@ class ActivitiesRoute extends PageRouteInfo<void> { | ||||
| class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { | ||||
|   MapLocationPickerRoute({ | ||||
|     Key? key, | ||||
|     LatLng? initialLatLng, | ||||
|     LatLng initialLatLng = const LatLng(0, 0), | ||||
|   }) : super( | ||||
|           MapLocationPickerRoute.name, | ||||
|           path: '/map-location-picker-page', | ||||
| @@ -1609,12 +1609,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { | ||||
| class MapLocationPickerRouteArgs { | ||||
|   const MapLocationPickerRouteArgs({ | ||||
|     this.key, | ||||
|     this.initialLatLng, | ||||
|     this.initialLatLng = const LatLng(0, 0), | ||||
|   }); | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final LatLng? initialLatLng; | ||||
|   final LatLng initialLatLng; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
|  | ||||
| part 'store.g.dart'; | ||||
|  | ||||
| @@ -8,6 +9,7 @@ part 'store.g.dart'; | ||||
| /// Supports String, int and JSON-serializable Objects | ||||
| /// Can be used concurrently from multiple isolates | ||||
| class Store { | ||||
|   static final Logger _log = Logger("Store"); | ||||
|   static late final Isar _db; | ||||
|   static final List<dynamic> _cache = | ||||
|       List.filled(StoreKey.values.map((e) => e.id).max + 1, null); | ||||
| @@ -72,8 +74,12 @@ class Store { | ||||
|   static void _onChangeListener(List<StoreValue>? data) { | ||||
|     if (data != null) { | ||||
|       for (StoreValue value in data) { | ||||
|         _cache[value.id] = | ||||
|             value._extract(StoreKey.values.firstWhere((e) => e.id == value.id)); | ||||
|         final key = StoreKey.values.firstWhereOrNull((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), | ||||
|   preferRemoteImage<bool>(116, type: bool), | ||||
|   // map related settings | ||||
|   mapThemeMode<bool>(117, type: bool), | ||||
|   mapShowFavoriteOnly<bool>(118, type: bool), | ||||
|   mapRelativeDate<int>(119, type: int), | ||||
|   selfSignedCert<bool>(120, type: bool), | ||||
|   mapIncludeArchived<bool>(121, type: bool), | ||||
|   ignoreIcloudAssets<bool>(122, type: bool), | ||||
|   selectedAlbumSortReverse<bool>(123, type: bool), | ||||
|   mapThemeMode<int>(124, type: int), | ||||
|   ; | ||||
|  | ||||
|   const StoreKey( | ||||
|   | ||||
| @@ -94,7 +94,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | ||||
|  | ||||
|   final _log = Logger('WebsocketNotifier'); | ||||
|   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 | ||||
|   void connect() { | ||||
| @@ -194,7 +195,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | ||||
|         PendingChange(now.millisecondsSinceEpoch.toString(), action, value), | ||||
|       ], | ||||
|     ); | ||||
|     _debounce(handlePendingChanges); | ||||
|     _debounce.run(handlePendingChanges); | ||||
|   } | ||||
|  | ||||
|   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/sync.service.dart'; | ||||
| import 'package:isar/isar.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| final assetServiceProvider = Provider( | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/extensions/build_context_extensions.dart'; | ||||
|  | ||||
| class CustomDraggingHandle extends StatelessWidget { | ||||
|   const CustomDraggingHandle({super.key}); | ||||
| @@ -6,11 +7,11 @@ class CustomDraggingHandle extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Container( | ||||
|       height: 5, | ||||
|       height: 4, | ||||
|       width: 30, | ||||
|       decoration: BoxDecoration( | ||||
|         color: Colors.grey[500], | ||||
|         borderRadius: BorderRadius.circular(16), | ||||
|         color: context.themeData.dividerColor, | ||||
|         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/services.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/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:latlong2/latlong.dart'; | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
|  | ||||
| Future<LatLng?> showLocationPicker({ | ||||
|   required BuildContext context, | ||||
| @@ -25,16 +24,6 @@ Future<LatLng?> showLocationPicker({ | ||||
|  | ||||
| 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 { | ||||
|   final LatLng? initialLatLng; | ||||
|  | ||||
| @@ -48,56 +37,8 @@ class _LocationPicker extends HookWidget { | ||||
|     final longitude = useState(initialLatLng?.longitude ?? 0.0); | ||||
|     final latlng = LatLng(latitude.value, longitude.value); | ||||
|     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() { | ||||
|       isValidLatitude.value = _validateLat(latitudeController.text); | ||||
|       if (isValidLatitude.value) { | ||||
|         latitude.value = latitudeController.text.toDouble(); | ||||
|       } | ||||
|       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 { | ||||
|     Future<void> onMapTap() async { | ||||
|       final newLatLng = await context.pushRoute<LatLng?>( | ||||
|         MapLocationPickerRoute(initialLatLng: latlng), | ||||
|       ); | ||||
| @@ -105,129 +46,25 @@ class _LocationPicker extends HookWidget { | ||||
|         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( | ||||
|       contentPadding: const EdgeInsets.all(30), | ||||
|       alignment: Alignment.center, | ||||
|       content: SingleChildScrollView( | ||||
|         child: Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           children: [ | ||||
|             const Text( | ||||
|               "edit_location_dialog_title", | ||||
|               textAlign: TextAlign.center, | ||||
|             ).tr(), | ||||
|             const SizedBox( | ||||
|               height: 12, | ||||
|             ), | ||||
|             if (pickerMode.value == _LocationPickerMode.manual) | ||||
|               ...buildManualPickerMode(), | ||||
|             if (pickerMode.value == _LocationPickerMode.map) | ||||
|               ...buildMapPickerMode(), | ||||
|           ], | ||||
|         child: pickerMode.value == _LocationPickerMode.map | ||||
|             ? _MapPicker( | ||||
|                 key: ValueKey(latlng), | ||||
|                 latlng: latlng, | ||||
|                 onModeSwitch: () => | ||||
|                     pickerMode.value = _LocationPickerMode.manual, | ||||
|                 onMapTap: onMapTap, | ||||
|               ) | ||||
|             : _ManualPicker( | ||||
|                 latlng: latlng, | ||||
|                 onModeSwitch: () => pickerMode.value = _LocationPickerMode.map, | ||||
|                 onLatUpdated: (value) => latitude.value = value, | ||||
|                 onLonUpdated: (value) => longitude.value = value, | ||||
|               ), | ||||
|       ), | ||||
|       actions: [ | ||||
| @@ -242,7 +79,7 @@ class _LocationPicker extends HookWidget { | ||||
|           ).tr(), | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: validateAndPop, | ||||
|           onPressed: () => context.popRoute(latlng), | ||||
|           child: Text( | ||||
|             "action_common_update", | ||||
|             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 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
|  | ||||
| class Debounce { | ||||
|   Debounce(Duration interval) : _interval = interval.inMilliseconds; | ||||
|   final int _interval; | ||||
| /// Used to debounce function calls with the [interval] provided. | ||||
| class Debouncer { | ||||
|   Debouncer({required this.interval}); | ||||
|   final Duration interval; | ||||
|   Timer? _timer; | ||||
|   VoidCallback? action; | ||||
|   FutureOr<void> Function()? _lastAction; | ||||
|  | ||||
|   void call(VoidCallback? action) { | ||||
|     this.action = action; | ||||
|   void run(FutureOr<void> Function() action) { | ||||
|     _lastAction = action; | ||||
|     _timer?.cancel(); | ||||
|     _timer = Timer(Duration(milliseconds: _interval), _callAndRest); | ||||
|     _timer = Timer(interval, _callAndRest); | ||||
|   } | ||||
|  | ||||
|   void _callAndRest() { | ||||
|     action?.call(); | ||||
|     _lastAction?.call(); | ||||
|     _timer = null; | ||||
|   } | ||||
|  | ||||
|   void dispose() { | ||||
|     _timer?.cancel(); | ||||
|     _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/location_picker.dart'; | ||||
| import 'package:immich_mobile/shared/ui/share_dialog.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| import 'package:maplibre_gl/maplibre_gl.dart'; | ||||
|  | ||||
| void handleShareAssets( | ||||
|   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" | ||||
|     source: hosted | ||||
|     version: "0.11.2" | ||||
|   ansicolor: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: ansicolor | ||||
|       sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.2" | ||||
|   archive: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: archive | ||||
|       sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a" | ||||
|       sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.3.7" | ||||
|     version: "3.4.9" | ||||
|   args: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -385,14 +393,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -503,10 +503,10 @@ packages: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: flutter_launcher_icons | ||||
|       sha256: "559c600f056e7c704bd843723c21e01b5fba47e8824bd02422165bcc02a5de1d" | ||||
|       sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.9.3" | ||||
|     version: "0.13.1" | ||||
|   flutter_lints: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
| @@ -544,30 +544,14 @@ packages: | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     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: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: flutter_native_splash | ||||
|       sha256: "6777a3abb974021a39b5fdd2d46a03ca390e03903b6351f21d10e7ecc969f12d" | ||||
|       sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.2.16" | ||||
|     version: "2.3.7" | ||||
|   flutter_plugin_android_lifecycle: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -755,10 +739,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: image | ||||
|       sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6" | ||||
|       sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.3.0" | ||||
|     version: "4.1.3" | ||||
|   image_picker: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -884,14 +868,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.8.1" | ||||
|   latlong2: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: latlong2 | ||||
|       sha256: "08ef7282ba9f76e8495e49e2dc4d653015ac929dce5f92b375a415d30b407ea0" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.8.2" | ||||
|   lints: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -900,14 +876,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.1" | ||||
|   lists: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: lists | ||||
|       sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.1" | ||||
|   logging: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -916,6 +884,33 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -940,14 +935,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1163,14 +1150,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.7.3" | ||||
|   polylabel: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: polylabel | ||||
|       sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.1" | ||||
|   pool: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1187,22 +1166,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1520,14 +1483,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.1" | ||||
|   tuple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: tuple | ||||
|       sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.2" | ||||
|   typed_data: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1536,14 +1491,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.2" | ||||
|   unicode: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: unicode | ||||
|       sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.3.1" | ||||
|   universal_io: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1624,15 +1571,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1641,22 +1579,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -1761,14 +1683,6 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -25,13 +25,12 @@ dependencies: | ||||
|   video_player: ^2.2.18 | ||||
|   chewie: ^1.4.0 | ||||
|   socket_io_client: ^2.0.0-beta.4-nullsafety.0 | ||||
|   flutter_map: ^4.0.0 | ||||
|   flutter_map_heatmap: ^0.0.4 | ||||
|   geolocator: ^10.0.0 # used to move to current location in map view | ||||
|   vector_map_tiles: | ||||
|   # Update it to tag once next stable release | ||||
|   maplibre_gl: | ||||
|       git: | ||||
|       url: https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git | ||||
|       ref: immich_above_4 | ||||
|         url: https://github.com/maplibre/flutter-maplibre-gl.git | ||||
|         ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 | ||||
|   geolocator: ^10.0.0 # used to move to current location in map view | ||||
|   flutter_udid: ^2.0.0 | ||||
|   package_info_plus: ^4.1.0 | ||||
|   url_launcher: ^6.1.3 | ||||
| @@ -40,10 +39,9 @@ dependencies: | ||||
|   easy_localization: ^3.0.1 | ||||
|   share_plus: ^7.1.0 | ||||
|   flutter_displaymode: ^0.4.0 | ||||
|   scrollable_positioned_list: ^0.3.4 | ||||
|   scrollable_positioned_list: ^0.3.8 | ||||
|   path: ^1.8.1 | ||||
|   path_provider: ^2.0.11 | ||||
|   latlong2: ^0.8.1 | ||||
|   collection: ^1.16.0 | ||||
|   http_parser: ^4.0.1 | ||||
|   flutter_web_auth: ^0.5.0 | ||||
| @@ -79,7 +77,7 @@ dev_dependencies: | ||||
|   flutter_lints: ^2.0.1 | ||||
|   build_runner: ^2.2.1 | ||||
|   auto_route_generator: ^5.0.2 | ||||
|   flutter_launcher_icons: "^0.9.2" | ||||
|   flutter_launcher_icons: ^0.13.1 | ||||
|   flutter_native_splash: ^2.2.16 | ||||
|   isar_generator: *isar_version | ||||
|   integration_test: | ||||
| @@ -117,11 +115,12 @@ flutter: | ||||
|       fonts: | ||||
|         - asset: fonts/overpass/OverpassMono.ttf | ||||
|  | ||||
| flutter_icons: | ||||
| flutter_launcher_icons: | ||||
|   image_path_android: "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" | ||||
|   ios: true # can specify file name here e.g. "My-Launcher-Icon | ||||
|   remove_alpha_ios: true | ||||
|  | ||||
| analyzer: | ||||
|   exclude: | ||||
|   | ||||
| @@ -203,7 +203,7 @@ void main() { | ||||
|     late ProviderContainer container; | ||||
|  | ||||
|     setUp(() async { | ||||
|       settingsMock = AppSettingsServiceMock(); | ||||
|       settingsMock = MockAppSettingsService(); | ||||
|       container = TestUtils.createContainer( | ||||
|         overrides: [ | ||||
|           appSettingsServiceProvider.overrideWith((ref) => settingsMock), | ||||
| @@ -283,7 +283,7 @@ void main() { | ||||
|     late ProviderContainer container; | ||||
|  | ||||
|     setUp(() async { | ||||
|       settingsMock = AppSettingsServiceMock(); | ||||
|       settingsMock = MockAppSettingsService(); | ||||
|       container = TestUtils.createContainer( | ||||
|         overrides: [ | ||||
|           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: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