Jelajahi Sumber

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>
shalong-tanwen 1 tahun lalu
induk
melakukan
cb391342d7
37 mengubah file dengan 2267 tambahan dan 138 penghapusan
  1. 1 0
      mobile/android/app/src/main/AndroidManifest.xml
  2. 16 1
      mobile/assets/i18n/en-US.json
  3. TEMPAT SAMPAH
      mobile/assets/lighthouse.png
  4. 6 0
      mobile/ios/Podfile.lock
  5. 0 2
      mobile/ios/Runner/Info.plist
  6. 7 21
      mobile/lib/modules/archive/views/archive_page.dart
  7. 28 54
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  8. 6 13
      mobile/lib/modules/favorite/views/favorites_page.dart
  9. 6 0
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  10. 7 1
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  11. 4 35
      mobile/lib/modules/home/views/home_page.dart
  12. 40 0
      mobile/lib/modules/map/models/map_page_event.model.dart
  13. 45 0
      mobile/lib/modules/map/models/map_state.model.dart
  14. 58 0
      mobile/lib/modules/map/providers/map_marker.provider.dart
  15. 51 0
      mobile/lib/modules/map/providers/map_state.provider.dart
  16. 62 0
      mobile/lib/modules/map/services/map.service.dart
  17. 144 0
      mobile/lib/modules/map/ui/asset_marker_icon.dart
  18. 30 0
      mobile/lib/modules/map/ui/location_dialog.dart
  19. 138 0
      mobile/lib/modules/map/ui/map_page_app_bar.dart
  20. 356 0
      mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
  21. 193 0
      mobile/lib/modules/map/ui/map_settings_dialog.dart
  22. 76 0
      mobile/lib/modules/map/ui/map_thumbnail.dart
  23. 499 0
      mobile/lib/modules/map/views/map_page.dart
  24. 110 0
      mobile/lib/modules/search/ui/curated_places_row.dart
  25. 4 3
      mobile/lib/modules/search/views/search_page.dart
  26. 3 0
      mobile/lib/modules/settings/services/app_settings.service.dart
  27. 2 0
      mobile/lib/routing/router.dart
  28. 25 0
      mobile/lib/routing/router.gr.dart
  29. 4 0
      mobile/lib/shared/models/store.dart
  30. 2 2
      mobile/lib/shared/ui/confirm_dialog.dart
  31. 104 0
      mobile/lib/utils/color_filter_generator.dart
  32. 26 0
      mobile/lib/utils/debounce.dart
  33. 67 0
      mobile/lib/utils/flutter_map_extensions.dart
  34. 13 6
      mobile/lib/utils/image_url_builder.dart
  35. 76 0
      mobile/lib/utils/selection_handlers.dart
  36. 56 0
      mobile/pubspec.lock
  37. 2 0
      mobile/pubspec.yaml

+ 1 - 0
mobile/android/app/src/main/AndroidManifest.xml

@@ -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>

+ 16 - 1
mobile/assets/i18n/en-US.json

@@ -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"
 }

TEMPAT SAMPAH
mobile/assets/lighthouse.png


+ 6 - 0
mobile/ios/Podfile.lock

@@ -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

+ 0 - 2
mobile/ios/Runner/Info.plist

@@ -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>

+ 7 - 21
mobile/lib/modules/archive/views/archive_page.dart

@@ -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(
-                                      selection.value.toList(),
-                                      false,
-                                    );
-
-                                final assetOrAssets = selection.value.length > 1
-                                    ? 'assets'
-                                    : 'asset';
-                                ImmichToast.show(
-                                  context: context,
-                                  msg:
-                                      'Moved ${selection.value.length} $assetOrAssets to library',
-                                  gravity: ToastGravity.CENTER,
-                                );
-                              }
+                              await handleArchiveAssets(
+                                ref,
+                                context,
+                                selection.value.toList(),
+                                shouldArchive: false,
+                              );
                             } finally {
                               processing.value = false;
                               selectionEnabledHook.value = false;

+ 28 - 54
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -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,65 +81,35 @@ 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)),
+            return MapThumbnail(
+              coords: LatLng(
+                exifInfo?.latitude ?? 0,
+                exifInfo?.longitude ?? 0,
               ),
-              child: FlutterMap(
-                options: MapOptions(
-                  interactiveFlags: InteractiveFlag.none,
-                  center: LatLng(
+              height: 150,
+              zoom: 16.0,
+              markers: [
+                Marker(
+                  anchorPos: AnchorPos.align(AnchorAlign.top),
+                  point: LatLng(
                     exifInfo?.latitude ?? 0,
                     exifInfo?.longitude ?? 0,
                   ),
-                  zoom: 16.0,
-                  onTap: (tapPosition, latLong) async {
-                    Uri? uri = await _createCoordinatesUri();
+                  builder: (ctx) => const Image(
+                    image: AssetImage('assets/location-pin.png'),
+                  ),
+                ),
+              ],
+              onTap: (tapPosition, latLong) async {
+                Uri? uri = await _createCoordinatesUri();
 
-                    if (uri == null) {
-                      return;
-                    }
+                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),
-                        point: LatLng(
-                          exifInfo?.latitude ?? 0,
-                          exifInfo?.longitude ?? 0,
-                        ),
-                        builder: (ctx) => const Image(
-                          image: AssetImage('assets/location-pin.png'),
-                        ),
-                      ),
-                    ],
-                  ),
-                ],
-              ),
+                debugPrint('Opening Map Uri: $uri');
+                launchUrl(uri);
+              },
             );
           },
         ),

+ 6 - 13
mobile/lib/modules/favorite/views/favorites_page.dart

@@ -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(
-                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,
+          await handleFavoriteAssets(
+            ref,
+            context,
+            selection.value.toList(),
+            shouldFavorite: false,
           );
         }
       } finally {

+ 6 - 0
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -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,
         ),
       );
     }

+ 7 - 1
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -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

+ 4 - 35
mobile/lib/modules/home/views/home_page.dart

@@ -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 - 0
mobile/lib/modules/map/models/map_page_event.model.dart

@@ -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 - 0
mobile/lib/modules/map/models/map_state.model.dart

@@ -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 - 0
mobile/lib/modules/map/providers/map_marker.provider.dart

@@ -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 - 0
mobile/lib/modules/map/providers/map_state.provider.dart

@@ -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 - 0
mobile/lib/modules/map/services/map.service.dart

@@ -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 - 0
mobile/lib/modules/map/ui/asset_marker_icon.dart

@@ -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 - 0
mobile/lib/modules/map/ui/location_dialog.dart

@@ -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 - 0
mobile/lib/modules/map/ui/map_page_app_bar.dart

@@ -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 - 0
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart

@@ -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 - 0
mobile/lib/modules/map/ui/map_settings_dialog.dart

@@ -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 - 0
mobile/lib/modules/map/ui/map_thumbnail.dart

@@ -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 - 0
mobile/lib/modules/map/views/map_page.dart

@@ -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 - 0
mobile/lib/modules/search/ui/curated_places_row.dart

@@ -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,
+    );
+  }
+}

+ 4 - 3
mobile/lib/modules/search/views/search_page.dart

@@ -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(

+ 3 - 0
mobile/lib/modules/settings/services/app_settings.service.dart

@@ -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);

+ 2 - 0
mobile/lib/routing/router.dart

@@ -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]),
   ],
 )

+ 25 - 0
mobile/lib/routing/router.gr.dart

@@ -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> {

+ 4 - 0
mobile/lib/shared/models/store.dart

@@ -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(

+ 2 - 2
mobile/lib/shared/ui/confirm_dialog.dart

@@ -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 - 0
mobile/lib/utils/color_filter_generator.dart

@@ -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 - 0
mobile/lib/utils/debounce.dart

@@ -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 - 0
mobile/lib/utils/flutter_map_extensions.dart

@@ -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;
+  }
+}

+ 13 - 6
mobile/lib/utils/image_url_builder.dart

@@ -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 - 0
mobile/lib/utils/selection_handlers.dart

@@ -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,
+      );
+    }
+  }
+}

+ 56 - 0
mobile/pubspec.lock

@@ -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:

+ 2 - 0
mobile/pubspec.yaml

@@ -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