mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(mobile): map view (#3661)
* feat(mobile): map page - add map view * map: add map-markers * feat(map): add relative date filter * fix: do not let users scroll past map bounds * fix: fetch relative date from store to state on init * feat(mobile):re-fetch markers only on filter change * feat(mobile) - asset bottom sheet in map page * feat(mobile): display markers based on bottom sheet scroll * fix: exif-bottom-sheet - rebase conflict * feat(mobile): map-view - strongly typed map page events * feat(map): zoom to asset * chore: dart analyzer fixes * map-page move attribution to top-right * feat(mobile): map view - asset selection handling * feat(mobile): map-view display map in places row * fix: make asset marker icon responsive * optimise map page rebuilds * refactor(mobile): map page * feat(mobile): map-view: Go to location * map-view(mobile): minor refactor * fix(mobile): Handle invalid coords gracefully * small styling --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -64,6 +64,7 @@ | ||||
|   <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> | ||||
|   <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> | ||||
|   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | ||||
|   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | ||||
|  | ||||
|   <queries> | ||||
|     <intent> | ||||
|   | ||||
| @@ -301,5 +301,20 @@ | ||||
|   "version_announcement_overlay_text_2": "please take your time to visit the ", | ||||
|   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", | ||||
|   "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", | ||||
|   "translated_text_options": "Options" | ||||
|   "translated_text_options": "Options", | ||||
|   "map_no_assets_in_bounds": "No photos in this area", | ||||
|   "map_zoom_to_see_photos": "Zoom out to see photos", | ||||
|   "map_settings_dialog_title": "Map Settings", | ||||
|   "map_settings_dark_mode": "Dark mode", | ||||
|   "map_settings_only_show_favorites": "Show Favorite Only", | ||||
|   "map_settings_only_relative_range": "Date range", | ||||
|   "map_settings_dialog_cancel": "Cancel", | ||||
|   "map_settings_dialog_save": "Save", | ||||
|   "map_cannot_get_user_location": "Cannot get user's location", | ||||
|   "map_location_service_disabled_title": "Location Service disabled", | ||||
|   "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", | ||||
|   "map_no_location_permission_title": "Location Permission denied", | ||||
|   "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", | ||||
|   "map_location_dialog_cancel": "Cancel", | ||||
|   "map_location_dialog_yes": "Yes" | ||||
| } | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								mobile/assets/lighthouse.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								mobile/assets/lighthouse.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 45 KiB | 
| @@ -20,6 +20,8 @@ PODS: | ||||
|   - FMDB (2.7.5): | ||||
|     - FMDB/standard (= 2.7.5) | ||||
|   - FMDB/standard (2.7.5) | ||||
|   - geolocator_apple (1.2.0): | ||||
|     - Flutter | ||||
|   - image_picker_ios (0.0.1): | ||||
|     - Flutter | ||||
|   - integration_test (0.0.1): | ||||
| @@ -65,6 +67,7 @@ DEPENDENCIES: | ||||
|   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) | ||||
|   - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`) | ||||
|   - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) | ||||
|   - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) | ||||
|   - 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`) | ||||
| @@ -104,6 +107,8 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/flutter_web_auth/ios" | ||||
|   fluttertoast: | ||||
|     :path: ".symlinks/plugins/fluttertoast/ios" | ||||
|   geolocator_apple: | ||||
|     :path: ".symlinks/plugins/geolocator_apple/ios" | ||||
|   image_picker_ios: | ||||
|     :path: ".symlinks/plugins/image_picker_ios/ios" | ||||
|   integration_test: | ||||
| @@ -143,6 +148,7 @@ SPEC CHECKSUMS: | ||||
|   flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d | ||||
|   fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c | ||||
|   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a | ||||
|   geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401 | ||||
|   image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 | ||||
|   integration_test: 13825b8a9334a850581300559b8839134b124670 | ||||
|   isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 | ||||
|   | ||||
| @@ -83,8 +83,6 @@ | ||||
|     </dict> | ||||
|     <key>NSCameraUsageDescription</key> | ||||
|     <string>We need to access the camera to let you take beautiful video using this app</string> | ||||
|     <key>NSLocationAlwaysUsageDescription</key> | ||||
|     <string>Enable location setting to show position of assets on map</string> | ||||
|     <key>NSLocationWhenInUseUsageDescription</key> | ||||
|     <string>Enable location setting to show position of assets on map</string> | ||||
|     <key>NSMicrophoneUsageDescription</key> | ||||
|   | ||||
| @@ -2,14 +2,12 @@ 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:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/utils/selection_handlers.dart'; | ||||
|  | ||||
| class ArchivePage extends HookConsumerWidget { | ||||
|   const ArchivePage({super.key}); | ||||
| @@ -68,24 +66,12 @@ class ArchivePage extends HookConsumerWidget { | ||||
|                         : () async { | ||||
|                             processing.value = true; | ||||
|                             try { | ||||
|                               if (selection.value.isNotEmpty) { | ||||
|                                 await ref | ||||
|                                     .watch(assetProvider.notifier) | ||||
|                                     .toggleArchive( | ||||
|                               await handleArchiveAssets( | ||||
|                                 ref, | ||||
|                                 context, | ||||
|                                 selection.value.toList(), | ||||
|                                       false, | ||||
|                                 shouldArchive: false, | ||||
|                               ); | ||||
|  | ||||
|                                 final assetOrAssets = selection.value.length > 1 | ||||
|                                     ? 'assets' | ||||
|                                     : 'asset'; | ||||
|                                 ImmichToast.show( | ||||
|                                   context: context, | ||||
|                                   msg: | ||||
|                                       'Moved ${selection.value.length} $assetOrAssets to library', | ||||
|                                   gravity: ToastGravity.CENTER, | ||||
|                                 ); | ||||
|                               } | ||||
|                             } finally { | ||||
|                               processing.value = false; | ||||
|                               selectionEnabledHook.value = false; | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_map/flutter_map.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.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/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| @@ -41,7 +42,10 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|       Uri uri = Uri( | ||||
|         scheme: 'geo', | ||||
|         host: '$latitude,$longitude', | ||||
|         queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime}, | ||||
|         queryParameters: { | ||||
|           'z': '$zoomLevel', | ||||
|           'q': '$latitude,$longitude($formattedDateTime)', | ||||
|         }, | ||||
|       ); | ||||
|       if (await canLaunchUrl(uri)) { | ||||
|         return uri; | ||||
| @@ -77,50 +81,13 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|         padding: const EdgeInsets.symmetric(vertical: 16.0), | ||||
|         child: LayoutBuilder( | ||||
|           builder: (context, constraints) { | ||||
|             return Container( | ||||
|               height: 150, | ||||
|               width: constraints.maxWidth, | ||||
|               decoration: const BoxDecoration( | ||||
|                 borderRadius: BorderRadius.all(Radius.circular(15)), | ||||
|               ), | ||||
|               child: FlutterMap( | ||||
|                 options: MapOptions( | ||||
|                   interactiveFlags: InteractiveFlag.none, | ||||
|                   center: LatLng( | ||||
|             return MapThumbnail( | ||||
|               coords: LatLng( | ||||
|                 exifInfo?.latitude ?? 0, | ||||
|                 exifInfo?.longitude ?? 0, | ||||
|               ), | ||||
|               height: 150, | ||||
|               zoom: 16.0, | ||||
|                   onTap: (tapPosition, latLong) async { | ||||
|                     Uri? uri = await _createCoordinatesUri(); | ||||
|  | ||||
|                     if (uri == null) { | ||||
|                       return; | ||||
|                     } | ||||
|  | ||||
|                     debugPrint('Opening Map Uri: $uri'); | ||||
|                     launchUrl(uri); | ||||
|                   }, | ||||
|                 ), | ||||
|                 nonRotatedChildren: [ | ||||
|                   RichAttributionWidget( | ||||
|                     attributions: [ | ||||
|                       TextSourceAttribution( | ||||
|                         'OpenStreetMap contributors', | ||||
|                         onTap: () => launchUrl( | ||||
|                           Uri.parse('https://openstreetmap.org/copyright'), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ], | ||||
|                 children: [ | ||||
|                   TileLayer( | ||||
|                     urlTemplate: | ||||
|                         "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", | ||||
|                     subdomains: const ['a', 'b', 'c'], | ||||
|                   ), | ||||
|                   MarkerLayer( | ||||
|               markers: [ | ||||
|                 Marker( | ||||
|                   anchorPos: AnchorPos.align(AnchorAlign.top), | ||||
| @@ -133,9 +100,16 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|               onTap: (tapPosition, latLong) async { | ||||
|                 Uri? uri = await _createCoordinatesUri(); | ||||
|  | ||||
|                 if (uri == null) { | ||||
|                   return; | ||||
|                 } | ||||
|  | ||||
|                 debugPrint('Opening Map Uri: $uri'); | ||||
|                 launchUrl(uri); | ||||
|               }, | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|   | ||||
| @@ -2,13 +2,11 @@ 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:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/utils/selection_handlers.dart'; | ||||
|  | ||||
| class FavoritesPage extends HookConsumerWidget { | ||||
|   const FavoritesPage({Key? key}) : super(key: key); | ||||
| @@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget { | ||||
|     void unfavorite() async { | ||||
|       try { | ||||
|         if (selection.value.isNotEmpty) { | ||||
|           await ref.watch(assetProvider.notifier).toggleFavorite( | ||||
|           await handleFavoriteAssets( | ||||
|             ref, | ||||
|             context, | ||||
|             selection.value.toList(), | ||||
|                 false, | ||||
|               ); | ||||
|           final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; | ||||
|           ImmichToast.show( | ||||
|             context: context, | ||||
|             msg: | ||||
|                 'Removed ${selection.value.length} $assetOrAssets from favorites', | ||||
|             gravity: ToastGravity.CENTER, | ||||
|             shouldFavorite: false, | ||||
|           ); | ||||
|         } | ||||
|       } finally { | ||||
|   | ||||
| @@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget { | ||||
|   final void Function(ItemPosition start, ItemPosition end)? | ||||
|       visibleItemsListener; | ||||
|   final Widget? topWidget; | ||||
|   final bool shrinkWrap; | ||||
|   final bool showDragScroll; | ||||
|  | ||||
|   const ImmichAssetGrid({ | ||||
|     super.key, | ||||
| @@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget { | ||||
|     this.showMultiSelectIndicator = true, | ||||
|     this.visibleItemsListener, | ||||
|     this.topWidget, | ||||
|     this.shrinkWrap = false, | ||||
|     this.showDragScroll = true, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget { | ||||
|           visibleItemsListener: visibleItemsListener, | ||||
|           topWidget: topWidget, | ||||
|           heroOffset: heroOffset(), | ||||
|           shrinkWrap: shrinkWrap, | ||||
|           showDragScroll: showDragScroll, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget { | ||||
|       visibleItemsListener; | ||||
|   final Widget? topWidget; | ||||
|   final int heroOffset; | ||||
|   final bool shrinkWrap; | ||||
|   final bool showDragScroll; | ||||
|  | ||||
|   const ImmichAssetGridView({ | ||||
|     super.key, | ||||
| @@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget { | ||||
|     this.visibleItemsListener, | ||||
|     this.topWidget, | ||||
|     this.heroOffset = 0, | ||||
|     this.shrinkWrap = false, | ||||
|     this.showDragScroll = true, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
| @@ -324,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | ||||
|   } | ||||
|  | ||||
|   Widget _buildAssetGrid() { | ||||
|     final useDragScrolling = widget.renderList.totalAssets >= 20; | ||||
|     final useDragScrolling = | ||||
|         widget.showDragScroll && widget.renderList.totalAssets >= 20; | ||||
|  | ||||
|     void dragScrolling(bool active) { | ||||
|       if (active != _scrolling) { | ||||
| @@ -344,6 +349,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | ||||
|       itemCount: widget.renderList.elements.length + | ||||
|           (widget.topWidget != null ? 1 : 0), | ||||
|       addRepaintBoundaries: true, | ||||
|       shrinkWrap: widget.shrinkWrap, | ||||
|     ); | ||||
|  | ||||
|     final child = useDragScrolling | ||||
|   | ||||
| @@ -25,10 +25,9 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/user.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/websocket.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/share.service.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/shared/ui/share_dialog.dart'; | ||||
| import 'package:immich_mobile/utils/selection_handlers.dart'; | ||||
|  | ||||
| class HomePage extends HookConsumerWidget { | ||||
|   const HomePage({Key? key}) : super(key: key); | ||||
| @@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget { | ||||
|       } | ||||
|  | ||||
|       void onShareAssets() { | ||||
|         showDialog( | ||||
|           context: context, | ||||
|           builder: (BuildContext buildContext) { | ||||
|             ref | ||||
|                 .watch(shareServiceProvider) | ||||
|                 .shareAssets(selection.value.toList()) | ||||
|                 .then((_) => Navigator.of(buildContext).pop()); | ||||
|             return const ShareDialog(); | ||||
|           }, | ||||
|           barrierDismissible: false, | ||||
|         ); | ||||
|         handleShareAssets(ref, context, selection.value.toList()); | ||||
|  | ||||
|         selectionEnabledHook.value = false; | ||||
|       } | ||||
| @@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget { | ||||
|             localErrorMessage: 'home_page_favorite_err_local'.tr(), | ||||
|           ); | ||||
|           if (remoteAssets.isNotEmpty) { | ||||
|             await ref | ||||
|                 .watch(assetProvider.notifier) | ||||
|                 .toggleFavorite(remoteAssets, true); | ||||
|  | ||||
|             final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset'; | ||||
|             ImmichToast.show( | ||||
|               context: context, | ||||
|               msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites', | ||||
|               gravity: ToastGravity.BOTTOM, | ||||
|             ); | ||||
|             await handleFavoriteAssets(ref, context, remoteAssets); | ||||
|           } | ||||
|         } finally { | ||||
|           processing.value = false; | ||||
| @@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget { | ||||
|           final remoteAssets = remoteOnlySelection( | ||||
|             localErrorMessage: 'home_page_archive_err_local'.tr(), | ||||
|           ); | ||||
|           if (remoteAssets.isNotEmpty) { | ||||
|             await ref | ||||
|                 .read(assetProvider.notifier) | ||||
|                 .toggleArchive(remoteAssets, true); | ||||
|  | ||||
|             final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset'; | ||||
|             ImmichToast.show( | ||||
|               context: context, | ||||
|               msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive', | ||||
|               gravity: ToastGravity.CENTER, | ||||
|             ); | ||||
|           } | ||||
|           await handleArchiveAssets(ref, context, remoteAssets); | ||||
|         } finally { | ||||
|           processing.value = false; | ||||
|           selectionEnabledHook.value = false; | ||||
|   | ||||
							
								
								
									
										40
									
								
								mobile/lib/modules/map/models/map_page_event.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								mobile/lib/modules/map/models/map_page_event.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| 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); | ||||
| } | ||||
							
								
								
									
										45
									
								
								mobile/lib/modules/map/models/map_state.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								mobile/lib/modules/map/models/map_state.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| class MapState { | ||||
|   final bool isDarkTheme; | ||||
|   final bool showFavoriteOnly; | ||||
|   final int relativeTime; | ||||
|  | ||||
|   MapState({ | ||||
|     this.isDarkTheme = false, | ||||
|     this.showFavoriteOnly = false, | ||||
|     this.relativeTime = 0, | ||||
|   }); | ||||
|  | ||||
|   MapState copyWith({ | ||||
|     bool? isDarkTheme, | ||||
|     bool? showFavoriteOnly, | ||||
|     int? relativeTime, | ||||
|   }) { | ||||
|     return MapState( | ||||
|       isDarkTheme: isDarkTheme ?? this.isDarkTheme, | ||||
|       showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, | ||||
|       relativeTime: relativeTime ?? this.relativeTime, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is MapState && | ||||
|         other.isDarkTheme == isDarkTheme && | ||||
|         other.showFavoriteOnly == showFavoriteOnly && | ||||
|         other.relativeTime == relativeTime; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     return isDarkTheme.hashCode ^ | ||||
|         showFavoriteOnly.hashCode ^ | ||||
|         relativeTime.hashCode; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										58
									
								
								mobile/lib/modules/map/providers/map_marker.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								mobile/lib/modules/map/providers/map_marker.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.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'; | ||||
|  | ||||
| final mapMarkersProvider = | ||||
|     FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async { | ||||
|   final service = ref.read(mapServiceProvider); | ||||
|   final mapState = ref.read(mapStateNotifier); | ||||
|   DateTime? fileCreatedAfter; | ||||
|   bool? isFavorite; | ||||
|  | ||||
|   if (mapState.relativeTime != 0) { | ||||
|     fileCreatedAfter = | ||||
|         DateTime.now().subtract(Duration(days: mapState.relativeTime)); | ||||
|   } | ||||
|  | ||||
|   if (mapState.showFavoriteOnly) { | ||||
|     isFavorite = true; | ||||
|   } | ||||
|  | ||||
|   final markers = await service.getMapMarkers( | ||||
|     isFavorite: isFavorite, | ||||
|     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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										51
									
								
								mobile/lib/modules/map/providers/map_state.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								mobile/lib/modules/map/providers/map_state.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| 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'; | ||||
|  | ||||
| class MapStateNotifier extends StateNotifier<MapState> { | ||||
|   MapStateNotifier(this.appSettingsProvider) | ||||
|       : super( | ||||
|           MapState( | ||||
|             isDarkTheme: appSettingsProvider | ||||
|                 .getSetting<bool>(AppSettingsEnum.mapThemeMode), | ||||
|             showFavoriteOnly: appSettingsProvider | ||||
|                 .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly), | ||||
|             relativeTime: appSettingsProvider | ||||
|                 .getSetting<int>(AppSettingsEnum.mapRelativeDate), | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|   final AppSettingsService appSettingsProvider; | ||||
|  | ||||
|   bool get isDarkTheme => state.isDarkTheme; | ||||
|  | ||||
|   void switchTheme(bool isDarkTheme) { | ||||
|     appSettingsProvider.setSetting( | ||||
|       AppSettingsEnum.mapThemeMode, | ||||
|       isDarkTheme, | ||||
|     ); | ||||
|     state = state.copyWith(isDarkTheme: isDarkTheme); | ||||
|   } | ||||
|  | ||||
|   void switchFavoriteOnly(bool isFavoriteOnly) { | ||||
|     appSettingsProvider.setSetting( | ||||
|       AppSettingsEnum.mapShowFavoriteOnly, | ||||
|       appSettingsProvider, | ||||
|     ); | ||||
|     state = state.copyWith(showFavoriteOnly: isFavoriteOnly); | ||||
|   } | ||||
|  | ||||
|   void setRelativeTime(int relativeTime) { | ||||
|     appSettingsProvider.setSetting( | ||||
|       AppSettingsEnum.mapRelativeDate, | ||||
|       relativeTime, | ||||
|     ); | ||||
|     state = state.copyWith(relativeTime: relativeTime); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final mapStateNotifier = | ||||
|     StateNotifierProvider<MapStateNotifier, MapState>((ref) { | ||||
|   return MapStateNotifier(ref.watch(appSettingsServiceProvider)); | ||||
| }); | ||||
							
								
								
									
										62
									
								
								mobile/lib/modules/map/services/map.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								mobile/lib/modules/map/services/map.service.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| 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/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 { | ||||
|   final ApiService _apiService; | ||||
|   final Isar _db; | ||||
|   final log = Logger("MapService"); | ||||
|  | ||||
|   MapSerivce(this._apiService, this._db); | ||||
|  | ||||
|   Future<List<MapMarkerResponseDto>> getMapMarkers({ | ||||
|     bool? isFavorite, | ||||
|     DateTime? fileCreatedAfter, | ||||
|     DateTime? fileCreatedBefore, | ||||
|   }) async { | ||||
|     try { | ||||
|       final markers = await _apiService.assetApi.getMapMarkers( | ||||
|         isFavorite: isFavorite, | ||||
|         fileCreatedAfter: fileCreatedAfter, | ||||
|         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 Asset.remote(dto); | ||||
|     } catch (error, stack) { | ||||
|       log.severe( | ||||
|         "Cannot get asset for marker ${error.toString()}", | ||||
|         error, | ||||
|         stack, | ||||
|       ); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										144
									
								
								mobile/lib/modules/map/ui/asset_marker_icon.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								mobile/lib/modules/map/ui/asset_marker_icon.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
|  | ||||
| class AssetMarkerIcon extends StatelessWidget { | ||||
|   const AssetMarkerIcon({ | ||||
|     Key? key, | ||||
|     required this.id, | ||||
|     this.isDarkTheme = false, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   final String id; | ||||
|   final bool isDarkTheme; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final imageUrl = getThumbnailUrlForRemoteId(id); | ||||
|     final cacheKey = getThumbnailCacheKeyForRemoteId(id); | ||||
|     return LayoutBuilder( | ||||
|       builder: (context, constraints) { | ||||
|         return Stack( | ||||
|           children: [ | ||||
|             Positioned( | ||||
|               bottom: 0, | ||||
|               left: constraints.maxWidth * 0.5, | ||||
|               child: CustomPaint( | ||||
|                 painter: _PinPainter( | ||||
|                   primaryColor: isDarkTheme ? Colors.white : Colors.black, | ||||
|                   secondaryColor: isDarkTheme ? Colors.black : Colors.white, | ||||
|                   primaryRadius: constraints.maxHeight * 0.06, | ||||
|                   secondaryRadius: constraints.maxHeight * 0.038, | ||||
|                 ), | ||||
|                 child: SizedBox( | ||||
|                   height: constraints.maxHeight * 0.14, | ||||
|                   width: constraints.maxWidth * 0.14, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             Positioned( | ||||
|               top: constraints.maxHeight * 0.07, | ||||
|               left: constraints.maxWidth * 0.17, | ||||
|               child: CircleAvatar( | ||||
|                 radius: constraints.maxHeight * 0.40, | ||||
|                 backgroundColor: isDarkTheme ? Colors.white : Colors.black, | ||||
|                 child: CircleAvatar( | ||||
|                   radius: constraints.maxHeight * 0.37, | ||||
|                   backgroundImage: CachedNetworkImageProvider( | ||||
|                     imageUrl, | ||||
|                     cacheKey: cacheKey, | ||||
|                     headers: { | ||||
|                       "Authorization": | ||||
|                           "Bearer ${Store.get(StoreKey.accessToken)}", | ||||
|                     }, | ||||
|                     errorListener: () => | ||||
|                         const Icon(Icons.image_not_supported_outlined), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _PinPainter extends CustomPainter { | ||||
|   final Color primaryColor; | ||||
|   final Color secondaryColor; | ||||
|   final double primaryRadius; | ||||
|   final double secondaryRadius; | ||||
|  | ||||
|   _PinPainter({ | ||||
|     this.primaryColor = Colors.black, | ||||
|     this.secondaryColor = Colors.white, | ||||
|     required this.primaryRadius, | ||||
|     required this.secondaryRadius, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   void paint(Canvas canvas, Size size) { | ||||
|     Paint primaryBrush = Paint() | ||||
|       ..color = primaryColor | ||||
|       ..style = PaintingStyle.fill; | ||||
|  | ||||
|     Paint secondaryBrush = Paint() | ||||
|       ..color = secondaryColor | ||||
|       ..style = PaintingStyle.fill; | ||||
|  | ||||
|     Paint lineBrush = Paint() | ||||
|       ..color = primaryColor | ||||
|       ..style = PaintingStyle.stroke | ||||
|       ..strokeWidth = 2; | ||||
|  | ||||
|     canvas.drawCircle( | ||||
|       Offset(size.width / 2, size.height), | ||||
|       primaryRadius, | ||||
|       primaryBrush, | ||||
|     ); | ||||
|     canvas.drawCircle( | ||||
|       Offset(size.width / 2, size.height), | ||||
|       secondaryRadius, | ||||
|       secondaryBrush, | ||||
|     ); | ||||
|     canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); | ||||
|     // The line is to make the above triangluar path more prominent since it has a slight curve | ||||
|     canvas.drawLine( | ||||
|       Offset(size.width / 2, 0), | ||||
|       Offset( | ||||
|         size.width / 2, | ||||
|         size.height, | ||||
|       ), | ||||
|       lineBrush, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Path getTrianglePath(double x, double y) { | ||||
|     final firstEndPoint = Offset(x / 2, y); | ||||
|     final controlPoint = Offset(x / 2, y * 0.3); | ||||
|     final secondEndPoint = Offset(x, 0); | ||||
|  | ||||
|     return Path() | ||||
|       ..quadraticBezierTo( | ||||
|         controlPoint.dx, | ||||
|         controlPoint.dy, | ||||
|         firstEndPoint.dx, | ||||
|         firstEndPoint.dy, | ||||
|       ) | ||||
|       ..quadraticBezierTo( | ||||
|         controlPoint.dx, | ||||
|         controlPoint.dy, | ||||
|         secondEndPoint.dx, | ||||
|         secondEndPoint.dy, | ||||
|       ) | ||||
|       ..lineTo(0, 0); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool shouldRepaint(_PinPainter old) { | ||||
|     return old.primaryColor != primaryColor || | ||||
|         old.secondaryColor != secondaryColor; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										30
									
								
								mobile/lib/modules/map/ui/location_dialog.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								mobile/lib/modules/map/ui/location_dialog.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| 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: () {}, | ||||
|         ); | ||||
| } | ||||
							
								
								
									
										138
									
								
								mobile/lib/modules/map/ui/map_page_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								mobile/lib/modules/map/ui/map_page_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| 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: () => AutoRouter.of(context).pop(), | ||||
|           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: const EdgeInsets.only(top: 30), | ||||
|       child: Row( | ||||
|         mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|         children: [ | ||||
|           if (!selectionEnabled.value) ...buildNonSelectionWidgets(context), | ||||
|           if (selectionEnabled.value) ...buildSelectionWidgets(), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Size get preferredSize => const Size.fromHeight(100); | ||||
| } | ||||
							
								
								
									
										356
									
								
								mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,356 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| 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/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'; | ||||
| import 'package:url_launcher/url_launcher.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 lastAssetOffsetInSheet = -1; | ||||
|   late final DraggableScrollableController bottomSheetController; | ||||
|   late final Debounce debounce; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     bottomSheetController = DraggableScrollableController(); | ||||
|     debounce = Debounce( | ||||
|       const Duration(milliseconds: 200), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var isDarkMode = Theme.of(context).brightness == Brightness.dark; | ||||
|     double maxHeight = MediaQuery.of(context).size.height; | ||||
|     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; | ||||
|         lastAssetOffsetInSheet = -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; | ||||
|         lastAssetOffsetInSheet = rowOffset + columnOffset; | ||||
|         final asset = _cachedRenderList?.allAssets?[lastAssetOffsetInSheet]; | ||||
|         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: isDarkMode | ||||
|                       ? 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: Theme.of(context).textTheme.displayLarge?.color, | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ) | ||||
|           : const SizedBox.shrink(); | ||||
|     } | ||||
|  | ||||
|     void onTapMapButton() { | ||||
|       if (lastAssetOffsetInSheet != -1) { | ||||
|         widget.bottomSheetEventSC.add( | ||||
|           MapPageZoomToAsset( | ||||
|             _cachedRenderList?.allAssets?[lastAssetOffsetInSheet], | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget buildDragHandle(ScrollController scrollController) { | ||||
|       final textToDisplay = assetsInBound.value.isNotEmpty | ||||
|           ? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}" | ||||
|           : "map_no_assets_in_bounds".tr(); | ||||
|       final dragHandle = Container( | ||||
|         height: 75, | ||||
|         width: double.infinity, | ||||
|         decoration: BoxDecoration( | ||||
|           color: isDarkMode ? Colors.grey[900] : Colors.grey[100], | ||||
|         ), | ||||
|         child: Stack( | ||||
|           children: [ | ||||
|             Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.center, | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               children: [ | ||||
|                 const SizedBox(height: 12), | ||||
|                 const CustomDraggingHandle(), | ||||
|                 const SizedBox(height: 12), | ||||
|                 Text( | ||||
|                   textToDisplay, | ||||
|                   style: TextStyle( | ||||
|                     fontSize: 16, | ||||
|                     color: Theme.of(context).textTheme.displayLarge?.color, | ||||
|                     fontWeight: FontWeight.bold, | ||||
|                   ), | ||||
|                 ), | ||||
|                 Divider( | ||||
|                   color: Theme.of(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: Theme.of(context).textTheme.displayLarge?.color, | ||||
|                   ), | ||||
|                   iconSize: 20, | ||||
|                   tooltip: 'Zoom to bounds', | ||||
|                   onPressed: onTapMapButton, | ||||
|                 ), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|       return SingleChildScrollView( | ||||
|         controller: scrollController, | ||||
|         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; | ||||
|           lastAssetOffsetInSheet = -1; | ||||
|           isSheetScrolled.value = false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|       }, | ||||
|       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: isDarkMode ? 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: GestureDetector( | ||||
|               onTap: () => launchUrl( | ||||
|                 Uri.parse('https://openstreetmap.org/copyright'), | ||||
|               ), | ||||
|               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, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										193
									
								
								mobile/lib/modules/map/ui/map_settings_dialog.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								mobile/lib/modules/map/ui/map_settings_dialog.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,193 @@ | ||||
| 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/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 showRelativeDate = useState(mapSettings.relativeTime); | ||||
|     final ThemeData theme = Theme.of(context); | ||||
|  | ||||
|     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 buildDateRangeSetting() { | ||||
|       final now = DateTime.now(); | ||||
|       return DropdownMenu( | ||||
|         enableSearch: false, | ||||
|         enableFilter: false, | ||||
|         initialSelection: showRelativeDate.value, | ||||
|         onSelected: (value) { | ||||
|           showRelativeDate.value = value!; | ||||
|         }, | ||||
|         dropdownMenuEntries: [ | ||||
|           const DropdownMenuEntry(value: 0, label: "All"), | ||||
|           const DropdownMenuEntry( | ||||
|             value: 1, | ||||
|             label: "Past 24 hours", | ||||
|           ), | ||||
|           const DropdownMenuEntry( | ||||
|             value: 7, | ||||
|             label: "Past 7 days", | ||||
|           ), | ||||
|           const DropdownMenuEntry( | ||||
|             value: 30, | ||||
|             label: "Past 30 days", | ||||
|           ), | ||||
|           DropdownMenuEntry( | ||||
|             value: now | ||||
|                 .difference( | ||||
|                   DateTime( | ||||
|                     now.year - 1, | ||||
|                     now.month, | ||||
|                     now.day, | ||||
|                     now.hour, | ||||
|                     now.minute, | ||||
|                     now.second, | ||||
|                   ), | ||||
|                 ) | ||||
|                 .inDays, | ||||
|             label: "Past year", | ||||
|           ), | ||||
|           DropdownMenuEntry( | ||||
|             value: now | ||||
|                 .difference( | ||||
|                   DateTime( | ||||
|                     now.year - 3, | ||||
|                     now.month, | ||||
|                     now.day, | ||||
|                     now.hour, | ||||
|                     now.minute, | ||||
|                     now.second, | ||||
|                   ), | ||||
|                 ) | ||||
|                 .inDays, | ||||
|             label: "Past 3 years", | ||||
|           ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     List<Widget> getDialogActions() { | ||||
|       return <Widget>[ | ||||
|         TextButton( | ||||
|           onPressed: () => Navigator.of(context).pop(), | ||||
|           style: TextButton.styleFrom( | ||||
|             backgroundColor: | ||||
|                 mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700], | ||||
|           ), | ||||
|           child: Text( | ||||
|             "map_settings_dialog_cancel".tr(), | ||||
|             style: theme.textTheme.labelSmall?.copyWith( | ||||
|               fontWeight: FontWeight.bold, | ||||
|               color: | ||||
|                   mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100], | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         TextButton( | ||||
|           onPressed: () { | ||||
|             mapSettingsNotifier.switchTheme(isDarkMode.value); | ||||
|             mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value); | ||||
|             mapSettingsNotifier.setRelativeTime(showRelativeDate.value); | ||||
|             Navigator.of(context).pop(); | ||||
|           }, | ||||
|           style: TextButton.styleFrom( | ||||
|             backgroundColor: theme.primaryColor, | ||||
|           ), | ||||
|           child: Text( | ||||
|             "map_settings_dialog_save".tr(), | ||||
|             style: theme.textTheme.labelSmall?.copyWith( | ||||
|               fontWeight: FontWeight.bold, | ||||
|               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: MediaQuery.of(context).size.height * 0.6, | ||||
|           ), | ||||
|           child: ListView( | ||||
|             shrinkWrap: true, | ||||
|             children: [ | ||||
|               buildMapThemeSetting(), | ||||
|               buildFavoriteOnlySetting(), | ||||
|               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, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										76
									
								
								mobile/lib/modules/map/ui/map_thumbnail.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								mobile/lib/modules/map/ui/map_thumbnail.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_map/plugin_api.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/utils/color_filter_generator.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 bool showAttribution; | ||||
|   final bool isDarkTheme; | ||||
|  | ||||
|   const MapThumbnail({ | ||||
|     super.key, | ||||
|     required this.coords, | ||||
|     required this.height, | ||||
|     this.onTap, | ||||
|     this.zoom = 1, | ||||
|     this.showAttribution = true, | ||||
|     this.isDarkTheme = false, | ||||
|     this.markers = const [], | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final tileLayer = TileLayer( | ||||
|       urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", | ||||
|       subdomains: const ['a', 'b', 'c'], | ||||
|     ); | ||||
|  | ||||
|     return SizedBox( | ||||
|       height: height, | ||||
|       child: ClipRRect( | ||||
|         borderRadius: const BorderRadius.all(Radius.circular(15)), | ||||
|         child: FlutterMap( | ||||
|           options: MapOptions( | ||||
|             interactiveFlags: InteractiveFlag.none, | ||||
|             center: coords, | ||||
|             zoom: zoom, | ||||
|             onTap: onTap, | ||||
|           ), | ||||
|           nonRotatedChildren: [ | ||||
|             if (showAttribution) | ||||
|               RichAttributionWidget( | ||||
|                 animationConfig: const ScaleRAWA(), | ||||
|                 attributions: [ | ||||
|                   TextSourceAttribution( | ||||
|                     'OpenStreetMap contributors', | ||||
|                     onTap: () => launchUrl( | ||||
|                       Uri.parse('https://openstreetmap.org/copyright'), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|           ], | ||||
|           children: [ | ||||
|             isDarkTheme | ||||
|                 ? InvertionFilter( | ||||
|                     child: SaturationFilter( | ||||
|                       saturation: -1, | ||||
|                       child: tileLayer, | ||||
|                     ), | ||||
|                   ) | ||||
|                 : tileLayer, | ||||
|             if (markers.isNotEmpty) MarkerLayer(markers: markers), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										499
									
								
								mobile/lib/modules/map/views/map_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										499
									
								
								mobile/lib/modules/map/views/map_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,499 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| 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/modules/map/models/map_page_event.model.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/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/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/utils/color_filter_generator.dart'; | ||||
| import 'package:immich_mobile/utils/debounce.dart'; | ||||
| import 'package:immich_mobile/utils/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'; | ||||
|  | ||||
| class MapPage extends StatefulHookConsumerWidget { | ||||
|   const MapPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   MapPageState createState() => MapPageState(); | ||||
| } | ||||
|  | ||||
| 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; | ||||
|   late final Debounce debounce; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     mapController = MapController(); | ||||
|     bottomSheetEventStream = bottomSheetEventSC.stream; | ||||
|     // Map zoom events and move events are triggered often. Throttle the call to limit rebuilds | ||||
|     debounce = Debounce( | ||||
|       const Duration(milliseconds: 300), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     debounce.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   void reloadAssetsInBound( | ||||
|     Set<AssetMarkerData>? assetMarkers, { | ||||
|     bool forceReload = false, | ||||
|   }) { | ||||
|     final bounds = mapController.bounds; | ||||
|     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(), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void openAssetInViewer(Asset asset) { | ||||
|     AutoRouter.of(context).push( | ||||
|       GalleryViewerRoute( | ||||
|         initialIndex: 0, | ||||
|         loadAsset: (index) => asset, | ||||
|         totalAssets: 1, | ||||
|         heroOffset: 0, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @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); | ||||
|  | ||||
|     if (refetchMarkers.value) { | ||||
|       mapMarkerData.value = ref.watch(mapMarkersProvider).when( | ||||
|             skipLoadingOnRefresh: false, | ||||
|             error: (error, stackTrace) { | ||||
|               log.warning( | ||||
|                 "Cannot get map markers ${error.toString()}", | ||||
|                 error, | ||||
|                 stackTrace, | ||||
|               ); | ||||
|               showLoadingIndicator.value = false; | ||||
|               return {}; | ||||
|             }, | ||||
|             loading: () { | ||||
|               showLoadingIndicator.value = true; | ||||
|               return {}; | ||||
|             }, | ||||
|             data: (data) { | ||||
|               showLoadingIndicator.value = false; | ||||
|               refetchMarkers.value = false; | ||||
|               closestAssetMarker.value = null; | ||||
|               debounce( | ||||
|                 () => reloadAssetsInBound( | ||||
|                   mapMarkerData.value, | ||||
|                   forceReload: true, | ||||
|                 ), | ||||
|               ); | ||||
|               return data; | ||||
|             }, | ||||
|           ); | ||||
|     } | ||||
|  | ||||
|     ref.listen(mapStateNotifier, (previous, next) { | ||||
|       bool shouldRefetch = | ||||
|           previous?.showFavoriteOnly != next.showFavoriteOnly || | ||||
|               previous?.relativeTime != next.relativeTime; | ||||
|       if (shouldRefetch) { | ||||
|         refetchMarkers.value = shouldRefetch; | ||||
|         ref.invalidate(mapMarkersProvider); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     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: 6, | ||||
|           ); | ||||
|           if (newCenter != null) { | ||||
|             forceAssetUpdate = true; | ||||
|             mapController.move(newCenter, 6); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     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), | ||||
|           12, | ||||
|         ); | ||||
|       } catch (error) { | ||||
|         log.severe( | ||||
|           "Cannot get user's current location due to ${error.toString()}", | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           ImmichToast.show( | ||||
|             context: context, | ||||
|             gravity: ToastGravity.BOTTOM, | ||||
|             toastType: ToastType.error, | ||||
|             msg: "map_cannot_get_user_location".tr(), | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     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, | ||||
|               ); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } else if (event is MapPageZoomToAsset) { | ||||
|         onZoomToAssetEvent(event.asset); | ||||
|       } else if (event is MapPageZoomToLocation) { | ||||
|         onZoomToLocation(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect( | ||||
|       () { | ||||
|         final bottomSheetEventSubscription = | ||||
|             bottomSheetEventStream.listen(handleBottomSheetEvents); | ||||
|         return bottomSheetEventSubscription.cancel; | ||||
|       }, | ||||
|       [bottomSheetEventStream], | ||||
|     ); | ||||
|  | ||||
|     void handleMapTapEvent(LatLng tapPosition) { | ||||
|       const d = Distance(); | ||||
|       final assetsInBoundsList = assetsInBounds.toList(); | ||||
|       assetsInBoundsList.sort( | ||||
|         (a, b) => d | ||||
|             .distance(a.point, tapPosition) | ||||
|             .compareTo(d.distance(b.point, tapPosition)), | ||||
|       ); | ||||
|       // First asset less than the threshold from the tap point | ||||
|       final nearestAsset = assetsInBoundsList.firstWhereOrNull( | ||||
|         (element) => | ||||
|             d.distance(element.point, tapPosition) < | ||||
|             mapController.getTapThresholdForZoomLevel(), | ||||
|       ); | ||||
|       // Reset marker if no assets are near the tap point | ||||
|       if (nearestAsset == null && closestAssetMarker.value != null) { | ||||
|         selectionEnabledHook.value = false; | ||||
|         mapPageEventSC.add( | ||||
|           const MapPageOnTapEvent(), | ||||
|         ); | ||||
|       } | ||||
|       closestAssetMarker.value = nearestAsset; | ||||
|     } | ||||
|  | ||||
|     void onMapEvent(MapEvent mapEvent) { | ||||
|       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 tileLayer = TileLayer( | ||||
|       urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", | ||||
|       subdomains: const ['a', 'b', 'c'], | ||||
|       maxNativeZoom: 19, | ||||
|       maxZoom: 19, | ||||
|     ); | ||||
|  | ||||
|     final darkTileLayer = InvertionFilter( | ||||
|       child: SaturationFilter( | ||||
|         saturation: -1, | ||||
|         child: BrightnessFilter( | ||||
|           brightness: -1, | ||||
|           child: tileLayer, | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     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( | ||||
|                 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: Colors.black.withOpacity(0.5), | ||||
|         statusBarIconBrightness: Brightness.light, | ||||
|       ), | ||||
|       child: Theme( | ||||
|         // Override app theme based on map theme | ||||
|         data: isDarkTheme ? immichDarkTheme : immichLightTheme, | ||||
|         child: Scaffold( | ||||
|           appBar: MapAppBar( | ||||
|             isDarkTheme: isDarkTheme, | ||||
|             selectionEnabled: selectionEnabledHook, | ||||
|             selectedAssetsLength: selectedAssets.value.length, | ||||
|             onShare: onShareAsset, | ||||
|             onArchive: onArchiveAsset, | ||||
|             onFavorite: onFavoriteAsset, | ||||
|           ), | ||||
|           extendBodyBehindAppBar: true, | ||||
|           body: Stack( | ||||
|             children: [ | ||||
|               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: 18, // max level supported by OSM, | ||||
|                   onMapReady: () { | ||||
|                     mapController.mapEventStream.listen(onMapEvent); | ||||
|                   }, | ||||
|                 ), | ||||
|                 children: [ | ||||
|                   isDarkTheme ? darkTileLayer : tileLayer, | ||||
|                   heatMapLayer, | ||||
|                   markerLayer, | ||||
|                 ], | ||||
|               ), | ||||
|               MapPageBottomSheet( | ||||
|                 mapPageEventStream: mapPageEventSC.stream, | ||||
|                 bottomSheetEventSC: bottomSheetEventSC, | ||||
|                 selectionEnabled: selectionEnabledHook.value, | ||||
|                 selectionlistener: selectionListener, | ||||
|                 isDarkTheme: isDarkTheme, | ||||
|               ), | ||||
|               if (showLoadingIndicator.value) | ||||
|                 Positioned( | ||||
|                   top: MediaQuery.of(context).size.height * 0.35, | ||||
|                   left: MediaQuery.of(context).size.width * 0.425, | ||||
|                   child: const ImmichLoadingIndicator(), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AssetMarker extends Marker { | ||||
|   String remoteId; | ||||
|  | ||||
|   AssetMarker({ | ||||
|     super.key, | ||||
|     required this.remoteId, | ||||
|     super.anchorPos, | ||||
|     required super.point, | ||||
|     super.width = 100.0, | ||||
|     super.height = 100.0, | ||||
|     required super.builder, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										110
									
								
								mobile/lib/modules/search/ui/curated_places_row.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								mobile/lib/modules/search/ui/curated_places_row.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/curated_row.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
|  | ||||
| class CuratedPlacesRow extends CuratedRow { | ||||
|   const CuratedPlacesRow({ | ||||
|     super.key, | ||||
|     required super.content, | ||||
|     super.imageSize, | ||||
|     super.onTap, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     Widget buildMapThumbnail() { | ||||
|       return GestureDetector( | ||||
|         onTap: () => AutoRouter.of(context).push( | ||||
|           const MapRoute(), | ||||
|         ), | ||||
|         child: SizedBox( | ||||
|           height: imageSize, | ||||
|           width: imageSize, | ||||
|           child: Stack( | ||||
|             children: [ | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(right: 10.0), | ||||
|                 child: MapThumbnail( | ||||
|                   zoom: 2, | ||||
|                   coords: LatLng( | ||||
|                     47, | ||||
|                     5, | ||||
|                   ), | ||||
|                   height: imageSize, | ||||
|                   showAttribution: false, | ||||
|                   isDarkTheme: Theme.of(context).brightness == Brightness.dark, | ||||
|                 ), | ||||
|               ), | ||||
|               Container( | ||||
|                 decoration: BoxDecoration( | ||||
|                   borderRadius: BorderRadius.circular(10), | ||||
|                   color: Colors.black, | ||||
|                   gradient: LinearGradient( | ||||
|                     begin: FractionalOffset.topCenter, | ||||
|                     end: FractionalOffset.bottomCenter, | ||||
|                     colors: [ | ||||
|                       Colors.blueGrey.withOpacity(0.0), | ||||
|                       Colors.black.withOpacity(0.4), | ||||
|                     ], | ||||
|                     stops: const [0.0, 1.0], | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|               const Align( | ||||
|                 alignment: Alignment.bottomCenter, | ||||
|                 child: Padding( | ||||
|                   padding: EdgeInsets.only(bottom: 10), | ||||
|                   child: Text( | ||||
|                     "Your Map", | ||||
|                     style: TextStyle( | ||||
|                       color: Colors.white, | ||||
|                       fontWeight: FontWeight.bold, | ||||
|                       fontSize: 14, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return ListView.builder( | ||||
|       scrollDirection: Axis.horizontal, | ||||
|       padding: const EdgeInsets.symmetric( | ||||
|         horizontal: 16, | ||||
|       ), | ||||
|       itemBuilder: (context, index) { | ||||
|         // Injecting Map thumbnail as the first element | ||||
|         if (index == 0) { | ||||
|           return buildMapThumbnail(); | ||||
|         } | ||||
|         // The actual index is 1 less than the virutal index since we inject map into the first position | ||||
|         final actualIndex = index - 1; | ||||
|         final object = content[actualIndex]; | ||||
|         final thumbnailRequestUrl = | ||||
|             '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}'; | ||||
|         return SizedBox( | ||||
|           width: imageSize, | ||||
|           height: imageSize, | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.only(right: 10.0), | ||||
|             child: ThumbnailWithInfo( | ||||
|               imageUrl: thumbnailRequestUrl, | ||||
|               textInfo: object.label, | ||||
|               onTap: () => onTap?.call(object, actualIndex), | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|       // Adding 1 to inject map thumbnail as first element | ||||
|       itemCount: content.length + 1, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/search/models/curated_content.dart'; | ||||
| import 'package:immich_mobile/modules/search/providers/people.provider.dart'; | ||||
| import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/curated_row.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/curated_places_row.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; | ||||
| import 'package:immich_mobile/modules/search/ui/search_row_title.dart'; | ||||
| @@ -69,7 +69,7 @@ class SearchPage extends HookConsumerWidget { | ||||
|  | ||||
|     buildPeople() { | ||||
|       return SizedBox( | ||||
|         height: MediaQuery.of(context).size.width / 3, | ||||
|         height: imageSize, | ||||
|         child: curatedPeople.when( | ||||
|           loading: () => const Center(child: ImmichLoadingIndicator()), | ||||
|           error: (err, stack) => Center(child: Text('Error: $err')), | ||||
| @@ -105,7 +105,7 @@ class SearchPage extends HookConsumerWidget { | ||||
|         child: curatedLocation.when( | ||||
|           loading: () => const Center(child: ImmichLoadingIndicator()), | ||||
|           error: (err, stack) => Center(child: Text('Error: $err')), | ||||
|           data: (locations) => CuratedRow( | ||||
|           data: (locations) => CuratedPlacesRow( | ||||
|             content: locations | ||||
|                 .map( | ||||
|                   (o) => CuratedContent( | ||||
| @@ -155,6 +155,7 @@ class SearchPage extends HookConsumerWidget { | ||||
|                   ), | ||||
|                   top: 0, | ||||
|                 ), | ||||
|                 const SizedBox(height: 10.0), | ||||
|                 buildPlaces(), | ||||
|                 const SizedBox(height: 24.0), | ||||
|                 Padding( | ||||
|   | ||||
| @@ -46,6 +46,9 @@ 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), | ||||
|   mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false), | ||||
|   mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0), | ||||
|   ; | ||||
|  | ||||
|   const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart'; | ||||
| 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/map/views/map_page.dart'; | ||||
| import 'package:immich_mobile/modules/memories/models/memory.dart'; | ||||
| import 'package:immich_mobile/modules/memories/views/memory_page.dart'; | ||||
| import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart'; | ||||
| @@ -153,6 +154,7 @@ part 'router.gr.dart'; | ||||
|     ), | ||||
|     AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]), | ||||
|     AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]), | ||||
|     AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]), | ||||
|     AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]), | ||||
|   ], | ||||
| ) | ||||
|   | ||||
| @@ -296,6 +296,12 @@ class _$AppRouter extends RootStackRouter { | ||||
|         ), | ||||
|       ); | ||||
|     }, | ||||
|     MapRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|         routeData: routeData, | ||||
|         child: const MapPage(), | ||||
|       ); | ||||
|     }, | ||||
|     AlbumOptionsRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<AlbumOptionsRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
| @@ -605,6 +611,14 @@ class _$AppRouter extends RootStackRouter { | ||||
|             duplicateGuard, | ||||
|           ], | ||||
|         ), | ||||
|         RouteConfig( | ||||
|           MapRoute.name, | ||||
|           path: '/map-page', | ||||
|           guards: [ | ||||
|             authGuard, | ||||
|             duplicateGuard, | ||||
|           ], | ||||
|         ), | ||||
|         RouteConfig( | ||||
|           AlbumOptionsRoute.name, | ||||
|           path: '/album-options-page', | ||||
| @@ -1337,6 +1351,17 @@ class MemoryRouteArgs { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// [MapPage] | ||||
| class MapRoute extends PageRouteInfo<void> { | ||||
|   const MapRoute() | ||||
|       : super( | ||||
|           MapRoute.name, | ||||
|           path: '/map-page', | ||||
|         ); | ||||
|  | ||||
|   static const String name = 'MapRoute'; | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [AlbumOptionsPage] | ||||
| class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> { | ||||
|   | ||||
| @@ -174,6 +174,10 @@ enum StoreKey<T> { | ||||
|   advancedTroubleshooting<bool>(114, type: bool), | ||||
|   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), | ||||
|   ; | ||||
|  | ||||
|   const StoreKey( | ||||
|   | ||||
| @@ -26,7 +26,7 @@ class ConfirmDialog extends ConsumerWidget { | ||||
|       content: Text(content).tr(), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           onPressed: () => Navigator.of(context).pop(), | ||||
|           onPressed: () => Navigator.of(context).pop(false), | ||||
|           child: Text( | ||||
|             cancel, | ||||
|             style: TextStyle( | ||||
| @@ -38,7 +38,7 @@ class ConfirmDialog extends ConsumerWidget { | ||||
|         TextButton( | ||||
|           onPressed: () { | ||||
|             onOk(); | ||||
|             Navigator.of(context).pop(); | ||||
|             Navigator.of(context).pop(true); | ||||
|           }, | ||||
|           child: Text( | ||||
|             ok, | ||||
|   | ||||
							
								
								
									
										104
									
								
								mobile/lib/utils/color_filter_generator.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								mobile/lib/utils/color_filter_generator.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| import 'package:flutter/widgets.dart'; | ||||
|  | ||||
| class InvertionFilter extends StatelessWidget { | ||||
|   final Widget? child; | ||||
|   const InvertionFilter({super.key, this.child}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ColorFiltered( | ||||
|       colorFilter: const ColorFilter.matrix(<double>[ | ||||
|         -1, 0, 0, 0, 255, // | ||||
|         0, -1, 0, 0, 255, // | ||||
|         0, 0, -1, 0, 255, // | ||||
|         0, 0, 0, 1, 0, // | ||||
|       ]), | ||||
|       child: child, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // -1 - darkest, 1 - brightest, 0 - unchanged | ||||
| class BrightnessFilter extends StatelessWidget { | ||||
|   final Widget? child; | ||||
|   final double brightness; | ||||
|   const BrightnessFilter({super.key, this.child, this.brightness = 0}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ColorFiltered( | ||||
|       colorFilter: ColorFilter.matrix( | ||||
|         _ColorFilterGenerator.brightnessAdjustMatrix(brightness), | ||||
|       ), | ||||
|       child: child, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // -1 - greyscale, 1 - most saturated, 0 - unchanged | ||||
| class SaturationFilter extends StatelessWidget { | ||||
|   final Widget? child; | ||||
|   final double saturation; | ||||
|   const SaturationFilter({super.key, this.child, this.saturation = 0}); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ColorFiltered( | ||||
|       colorFilter: ColorFilter.matrix( | ||||
|         _ColorFilterGenerator.saturationAdjustMatrix(saturation), | ||||
|       ), | ||||
|       child: child, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _ColorFilterGenerator { | ||||
|   static List<double> brightnessAdjustMatrix(double value) { | ||||
|     value = value * 10; | ||||
|  | ||||
|     if (value == 0) { | ||||
|       return [ | ||||
|         1, 0, 0, 0, 0, // | ||||
|         0, 1, 0, 0, 0, // | ||||
|         0, 0, 1, 0, 0, // | ||||
|         0, 0, 0, 1, 0, // | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     return List<double>.from(<double>[ | ||||
|       1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, // | ||||
|     ]).map((i) => i.toDouble()).toList(); | ||||
|   } | ||||
|  | ||||
|   static List<double> saturationAdjustMatrix(double value) { | ||||
|     value = value * 100; | ||||
|  | ||||
|     if (value == 0) { | ||||
|       return [ | ||||
|         1, 0, 0, 0, 0, // | ||||
|         0, 1, 0, 0, 0, // | ||||
|         0, 0, 1, 0, 0, // | ||||
|         0, 0, 0, 1, 0, // | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     double x = | ||||
|         ((1 + ((value > 0) ? ((3 * value) / 100) : (value / 100)))).toDouble(); | ||||
|     double lumR = 0.3086; | ||||
|     double lumG = 0.6094; | ||||
|     double lumB = 0.082; | ||||
|  | ||||
|     return List<double>.from(<double>[ | ||||
|       (lumR * (1 - x)) + x, lumG * (1 - x), lumB * (1 - x), // | ||||
|       0, 0, // | ||||
|       lumR * (1 - x), // | ||||
|       (lumG * (1 - x)) + x, // | ||||
|       lumB * (1 - x), // | ||||
|       0, 0, // | ||||
|       lumR * (1 - x), // | ||||
|       lumG * (1 - x), // | ||||
|       (lumB * (1 - x)) + x, // | ||||
|       0, 0, 0, 0, 0, 1, 0, // | ||||
|     ]).map((i) => i.toDouble()).toList(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										26
									
								
								mobile/lib/utils/debounce.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								mobile/lib/utils/debounce.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class Debounce { | ||||
|   Debounce(Duration interval) : _interval = interval.inMilliseconds; | ||||
|   final int _interval; | ||||
|   Timer? _timer; | ||||
|   VoidCallback? action; | ||||
|  | ||||
|   void call(VoidCallback? action) { | ||||
|     this.action = action; | ||||
|     _timer?.cancel(); | ||||
|     _timer = Timer(Duration(milliseconds: _interval), _callAndRest); | ||||
|   } | ||||
|  | ||||
|   void _callAndRest() { | ||||
|     action?.call(); | ||||
|     _timer = null; | ||||
|   } | ||||
|  | ||||
|   void dispose() { | ||||
|     _timer?.cancel(); | ||||
|     _timer = null; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										67
									
								
								mobile/lib/utils/flutter_map_extensions.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								mobile/lib/utils/flutter_map_extensions.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| 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; | ||||
|   } | ||||
| } | ||||
| @@ -7,17 +7,20 @@ String getThumbnailUrl( | ||||
|   final Asset asset, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   return _getThumbnailUrl(asset.remoteId!, type: type); | ||||
|   return getThumbnailUrlForRemoteId(asset.remoteId!, type: type); | ||||
| } | ||||
|  | ||||
| String getThumbnailCacheKey( | ||||
|   final Asset asset, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   return _getThumbnailCacheKey(asset.remoteId!, type); | ||||
|   return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type); | ||||
| } | ||||
|  | ||||
| String _getThumbnailCacheKey(final String id, final ThumbnailFormat type) { | ||||
| String getThumbnailCacheKeyForRemoteId( | ||||
|   final String id, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   if (type == ThumbnailFormat.WEBP) { | ||||
|     return 'thumbnail-image-$id'; | ||||
|   } else { | ||||
| @@ -32,7 +35,8 @@ String getAlbumThumbnailUrl( | ||||
|   if (album.thumbnail.value?.remoteId == null) { | ||||
|     return ''; | ||||
|   } | ||||
|   return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type); | ||||
|   return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!, | ||||
|       type: type,); | ||||
| } | ||||
|  | ||||
| String getAlbumThumbNailCacheKey( | ||||
| @@ -42,7 +46,10 @@ String getAlbumThumbNailCacheKey( | ||||
|   if (album.thumbnail.value?.remoteId == null) { | ||||
|     return ''; | ||||
|   } | ||||
|   return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type); | ||||
|   return getThumbnailCacheKeyForRemoteId( | ||||
|     album.thumbnail.value!.remoteId!, | ||||
|     type: type, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| String getImageUrl(final Asset asset) { | ||||
| @@ -53,7 +60,7 @@ String getImageCacheKey(final Asset asset) { | ||||
|   return '${asset.id}_fullStage'; | ||||
| } | ||||
|  | ||||
| String _getThumbnailUrl( | ||||
| String getThumbnailUrlForRemoteId( | ||||
|   final String id, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   | ||||
							
								
								
									
										76
									
								
								mobile/lib/utils/selection_handlers.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								mobile/lib/utils/selection_handlers.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/share.service.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/shared/ui/share_dialog.dart'; | ||||
|  | ||||
| void handleShareAssets( | ||||
|   WidgetRef ref, | ||||
|   BuildContext context, | ||||
|   List<Asset> selection, | ||||
| ) { | ||||
|   showDialog( | ||||
|     context: context, | ||||
|     builder: (BuildContext buildContext) { | ||||
|       ref | ||||
|           .watch(shareServiceProvider) | ||||
|           .shareAssets(selection.toList()) | ||||
|           .then((_) => Navigator.of(buildContext).pop()); | ||||
|       return const ShareDialog(); | ||||
|     }, | ||||
|     barrierDismissible: false, | ||||
|   ); | ||||
| } | ||||
|  | ||||
| Future<void> handleArchiveAssets( | ||||
|   WidgetRef ref, | ||||
|   BuildContext context, | ||||
|   List<Asset> selection, { | ||||
|   bool shouldArchive = true, | ||||
|   ToastGravity toastGravity = ToastGravity.BOTTOM, | ||||
| }) async { | ||||
|   if (selection.isNotEmpty) { | ||||
|     await ref | ||||
|         .read(assetProvider.notifier) | ||||
|         .toggleArchive(selection, shouldArchive); | ||||
|  | ||||
|     final assetOrAssets = selection.length > 1 ? 'assets' : 'asset'; | ||||
|     final archiveOrLibrary = shouldArchive ? 'archive' : 'library'; | ||||
|     if (context.mounted) { | ||||
|       ImmichToast.show( | ||||
|         context: context, | ||||
|         msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary', | ||||
|         gravity: toastGravity, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> handleFavoriteAssets( | ||||
|   WidgetRef ref, | ||||
|   BuildContext context, | ||||
|   List<Asset> selection, { | ||||
|   bool shouldFavorite = true, | ||||
|   ToastGravity toastGravity = ToastGravity.BOTTOM, | ||||
| }) async { | ||||
|   if (selection.isNotEmpty) { | ||||
|     await ref | ||||
|         .watch(assetProvider.notifier) | ||||
|         .toggleFavorite(selection, shouldFavorite); | ||||
|  | ||||
|     final assetOrAssets = selection.length > 1 ? 'assets' : 'asset'; | ||||
|     final toastMessage = shouldFavorite | ||||
|         ? 'Added ${selection.length} $assetOrAssets to favorites' | ||||
|         : 'Removed ${selection.length} $assetOrAssets from favorites'; | ||||
|     if (context.mounted) { | ||||
|       ImmichToast.show( | ||||
|         context: context, | ||||
|         msg: toastMessage, | ||||
|         gravity: ToastGravity.BOTTOM, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -504,6 +504,14 @@ packages: | ||||
|       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: | ||||
| @@ -575,6 +583,54 @@ packages: | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   geolocator: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: geolocator | ||||
|       sha256: "9d6eff112971b9f195271834b390fc0e1899a9a6c96225ead72efd5d4aaa80c7" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|   geolocator_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: geolocator_android | ||||
|       sha256: "835ff5b4888a2f8eba128996494faf9c5d422785322a81dc0565b99e0f6c379d" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.2.2" | ||||
|   geolocator_apple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: geolocator_apple | ||||
|       sha256: "36527c555f4c425f7d8fa8c7c07d67b78e3ff7590d40448051959e1860c1cfb4" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.2.7" | ||||
|   geolocator_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: geolocator_platform_interface | ||||
|       sha256: af4d69231452f9620718588f41acc4cb58312368716bfff2e92e770b46ce6386 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.0.7" | ||||
|   geolocator_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: geolocator_web | ||||
|       sha256: f68a122da48fcfff68bbc9846bb0b74ef651afe84a1b1f6ec20939de4d6860e1 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.6" | ||||
|   geolocator_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: geolocator_windows | ||||
|       sha256: "463045515b08bd83f73e014359c4ad063b902eb3899952cfb784497ae6c6583b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|   glob: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -26,6 +26,8 @@ dependencies: | ||||
|   badges: ^2.0.2 | ||||
|   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 | ||||
|   flutter_udid: ^2.0.0 | ||||
|   package_info_plus: ^4.1.0 | ||||
|   url_launcher: ^6.1.3 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user