From cb391342d7afceab9d7d966633848ab395e3e0a0 Mon Sep 17 00:00:00 2001 From: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sun, 27 Aug 2023 05:07:35 +0000 Subject: [PATCH] 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 --- .../android/app/src/main/AndroidManifest.xml | 1 + mobile/assets/i18n/en-US.json | 17 +- mobile/assets/lighthouse.png | Bin 0 -> 45994 bytes mobile/ios/Podfile.lock | 6 + mobile/ios/Runner/Info.plist | 2 - .../modules/archive/views/archive_page.dart | 28 +- .../asset_viewer/ui/exif_bottom_sheet.dart | 84 +-- .../favorite/views/favorites_page.dart | 19 +- .../home/ui/asset_grid/immich_asset_grid.dart | 6 + .../ui/asset_grid/immich_asset_grid_view.dart | 8 +- mobile/lib/modules/home/views/home_page.dart | 39 +- .../map/models/map_page_event.model.dart | 40 ++ .../modules/map/models/map_state.model.dart | 45 ++ .../map/providers/map_marker.provider.dart | 58 ++ .../map/providers/map_state.provider.dart | 51 ++ .../lib/modules/map/services/map.service.dart | 62 +++ .../lib/modules/map/ui/asset_marker_icon.dart | 144 +++++ .../lib/modules/map/ui/location_dialog.dart | 30 ++ .../lib/modules/map/ui/map_page_app_bar.dart | 138 +++++ .../modules/map/ui/map_page_bottom_sheet.dart | 356 +++++++++++++ .../modules/map/ui/map_settings_dialog.dart | 193 +++++++ mobile/lib/modules/map/ui/map_thumbnail.dart | 76 +++ mobile/lib/modules/map/views/map_page.dart | 499 ++++++++++++++++++ .../modules/search/ui/curated_places_row.dart | 110 ++++ .../lib/modules/search/views/search_page.dart | 7 +- .../services/app_settings.service.dart | 3 + mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 25 + mobile/lib/shared/models/store.dart | 4 + mobile/lib/shared/ui/confirm_dialog.dart | 4 +- mobile/lib/utils/color_filter_generator.dart | 104 ++++ mobile/lib/utils/debounce.dart | 26 + mobile/lib/utils/flutter_map_extensions.dart | 67 +++ mobile/lib/utils/image_url_builder.dart | 19 +- mobile/lib/utils/selection_handlers.dart | 76 +++ mobile/pubspec.lock | 56 ++ mobile/pubspec.yaml | 2 + 37 files changed, 2268 insertions(+), 139 deletions(-) create mode 100644 mobile/assets/lighthouse.png create mode 100644 mobile/lib/modules/map/models/map_page_event.model.dart create mode 100644 mobile/lib/modules/map/models/map_state.model.dart create mode 100644 mobile/lib/modules/map/providers/map_marker.provider.dart create mode 100644 mobile/lib/modules/map/providers/map_state.provider.dart create mode 100644 mobile/lib/modules/map/services/map.service.dart create mode 100644 mobile/lib/modules/map/ui/asset_marker_icon.dart create mode 100644 mobile/lib/modules/map/ui/location_dialog.dart create mode 100644 mobile/lib/modules/map/ui/map_page_app_bar.dart create mode 100644 mobile/lib/modules/map/ui/map_page_bottom_sheet.dart create mode 100644 mobile/lib/modules/map/ui/map_settings_dialog.dart create mode 100644 mobile/lib/modules/map/ui/map_thumbnail.dart create mode 100644 mobile/lib/modules/map/views/map_page.dart create mode 100644 mobile/lib/modules/search/ui/curated_places_row.dart create mode 100644 mobile/lib/utils/color_filter_generator.dart create mode 100644 mobile/lib/utils/debounce.dart create mode 100644 mobile/lib/utils/flutter_map_extensions.dart create mode 100644 mobile/lib/utils/selection_handlers.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c99e634880..71ff1230db 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -64,6 +64,7 @@ + diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 3082febe15..ddf5b88e72 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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" } diff --git a/mobile/assets/lighthouse.png b/mobile/assets/lighthouse.png new file mode 100644 index 0000000000000000000000000000000000000000..e2df34e106ccc3a397f06f3b1fec275b10c2edfb GIT binary patch literal 45994 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 NSCameraUsageDescription We need to access the camera to let you take beautiful video using this app - NSLocationAlwaysUsageDescription - Enable location setting to show position of assets on map NSLocationWhenInUseUsageDescription Enable location setting to show position of assets on map NSMicrophoneUsageDescription diff --git a/mobile/lib/modules/archive/views/archive_page.dart b/mobile/lib/modules/archive/views/archive_page.dart index 4f63526c09..2e1c1cd4a6 100644 --- a/mobile/lib/modules/archive/views/archive_page.dart +++ b/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; diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index da01f9ec23..fcc1d4440d 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/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(); - - if (uri == null) { - return; - } - - debugPrint('Opening Map Uri: $uri'); - launchUrl(uri); - }, + builder: (ctx) => const Image( + image: AssetImage('assets/location-pin.png'), + ), ), - 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'), - ), - ), - ], - ), - ], - ), + ], + onTap: (tapPosition, latLong) async { + Uri? uri = await _createCoordinatesUri(); + + if (uri == null) { + return; + } + + debugPrint('Opening Map Uri: $uri'); + launchUrl(uri); + }, ); }, ), diff --git a/mobile/lib/modules/favorite/views/favorites_page.dart b/mobile/lib/modules/favorite/views/favorites_page.dart index c8d139fc79..62f8763bbc 100644 --- a/mobile/lib/modules/favorite/views/favorites_page.dart +++ b/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 { diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 8fdadb3dc1..c4a6d527ed 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/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, ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 599becac80..8f50c28832 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/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 { } 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 { itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0), addRepaintBoundaries: true, + shrinkWrap: widget.shrinkWrap, ); final child = useDragScrolling diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 10be18ce24..e37491440b 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/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; diff --git a/mobile/lib/modules/map/models/map_page_event.model.dart b/mobile/lib/modules/map/models/map_page_event.model.dart new file mode 100644 index 0000000000..63665173d9 --- /dev/null +++ b/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 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); +} diff --git a/mobile/lib/modules/map/models/map_state.model.dart b/mobile/lib/modules/map/models/map_state.model.dart new file mode 100644 index 0000000000..ed2b033fdf --- /dev/null +++ b/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; + } +} diff --git a/mobile/lib/modules/map/providers/map_marker.provider.dart b/mobile/lib/modules/map/providers/map_marker.provider.dart new file mode 100644 index 0000000000..30343f2806 --- /dev/null +++ b/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>((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; + } +} diff --git a/mobile/lib/modules/map/providers/map_state.provider.dart b/mobile/lib/modules/map/providers/map_state.provider.dart new file mode 100644 index 0000000000..7fd7d60614 --- /dev/null +++ b/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 { + MapStateNotifier(this.appSettingsProvider) + : super( + MapState( + isDarkTheme: appSettingsProvider + .getSetting(AppSettingsEnum.mapThemeMode), + showFavoriteOnly: appSettingsProvider + .getSetting(AppSettingsEnum.mapShowFavoriteOnly), + relativeTime: appSettingsProvider + .getSetting(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((ref) { + return MapStateNotifier(ref.watch(appSettingsServiceProvider)); +}); diff --git a/mobile/lib/modules/map/services/map.service.dart b/mobile/lib/modules/map/services/map.service.dart new file mode 100644 index 0000000000..ec8dbbb39e --- /dev/null +++ b/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> 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 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; + } + } +} diff --git a/mobile/lib/modules/map/ui/asset_marker_icon.dart b/mobile/lib/modules/map/ui/asset_marker_icon.dart new file mode 100644 index 0000000000..db6d1a10eb --- /dev/null +++ b/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; + } +} diff --git a/mobile/lib/modules/map/ui/location_dialog.dart b/mobile/lib/modules/map/ui/location_dialog.dart new file mode 100644 index 0000000000..a55202e145 --- /dev/null +++ b/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: () {}, + ); +} diff --git a/mobile/lib/modules/map/ui/map_page_app_bar.dart b/mobile/lib/modules/map/ui/map_page_app_bar.dart new file mode 100644 index 0000000000..c43cd9d3c4 --- /dev/null +++ b/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 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 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 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); +} diff --git a/mobile/lib/modules/map/ui/map_page_bottom_sheet.dart b/mobile/lib/modules/map/ui/map_page_bottom_sheet.dart new file mode 100644 index 0000000000..f74df4331c --- /dev/null +++ b/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 { + // 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([]); + 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( + 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/map/ui/map_settings_dialog.dart b/mobile/lib/modules/map/ui/map_settings_dialog.dart new file mode 100644 index 0000000000..d04ff2b85b --- /dev/null +++ b/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 getDialogActions() { + return [ + 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, + ); + } +} diff --git a/mobile/lib/modules/map/ui/map_thumbnail.dart b/mobile/lib/modules/map/ui/map_thumbnail.dart new file mode 100644 index 0000000000..78998276d8 --- /dev/null +++ b/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 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), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/map/views/map_page.dart b/mobile/lib/modules/map/views/map_page.dart new file mode 100644 index 0000000000..379b209d99 --- /dev/null +++ b/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 { + // Non-State variables + late final MapController mapController; + // Streams are used instead of callbacks to prevent unnecessary rebuilds on events + final StreamController mapPageEventSC = + StreamController.broadcast(); + final StreamController bottomSheetEventSC = + StreamController.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 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? 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> mapMarkerData = + useState({}); + final ValueNotifier closestAssetMarker = useState(null); + final selectionEnabledHook = useState(false); + final selectedAssets = useState({}); + 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 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( + 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, + }); +} diff --git a/mobile/lib/modules/search/ui/curated_places_row.dart b/mobile/lib/modules/search/ui/curated_places_row.dart new file mode 100644 index 0000000000..ef394b83b9 --- /dev/null +++ b/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, + ); + } +} diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index b94806730a..a7edcd90e2 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/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( diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart index e54e6b60e0..7ad93ea08a 100644 --- a/mobile/lib/modules/settings/services/app_settings.service.dart +++ b/mobile/lib/modules/settings/services/app_settings.service.dart @@ -46,6 +46,9 @@ enum AppSettingsEnum { advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), logLevel(StoreKey.logLevel, null, 5), // Level.INFO = 5 preferRemoteImage(StoreKey.preferRemoteImage, null, false), + mapThemeMode(StoreKey.mapThemeMode, null, false), + mapShowFavoriteOnly(StoreKey.mapShowFavoriteOnly, null, false), + mapRelativeDate(StoreKey.mapRelativeDate, null, 0), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 66bcf6de7f..56885aeaf3 100644 --- a/mobile/lib/routing/router.dart +++ b/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]), ], ) diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b1f7ec26d9..4aef3beabb 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -296,6 +296,12 @@ class _$AppRouter extends RootStackRouter { ), ); }, + MapRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const MapPage(), + ); + }, AlbumOptionsRoute.name: (routeData) { final args = routeData.argsAs(); return MaterialPageX( @@ -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 { + const MapRoute() + : super( + MapRoute.name, + path: '/map-page', + ); + + static const String name = 'MapRoute'; +} + /// generated route for /// [AlbumOptionsPage] class AlbumOptionsRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart index ebbef904e8..f67b2b4115 100644 --- a/mobile/lib/shared/models/store.dart +++ b/mobile/lib/shared/models/store.dart @@ -174,6 +174,10 @@ enum StoreKey { advancedTroubleshooting(114, type: bool), logLevel(115, type: int), preferRemoteImage(116, type: bool), + // map related settings + mapThemeMode(117, type: bool), + mapShowFavoriteOnly(118, type: bool), + mapRelativeDate(119, type: int), ; const StoreKey( diff --git a/mobile/lib/shared/ui/confirm_dialog.dart b/mobile/lib/shared/ui/confirm_dialog.dart index 87d77ecd01..773007f73c 100644 --- a/mobile/lib/shared/ui/confirm_dialog.dart +++ b/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, diff --git a/mobile/lib/utils/color_filter_generator.dart b/mobile/lib/utils/color_filter_generator.dart new file mode 100644 index 0000000000..c155823264 --- /dev/null +++ b/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([ + -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 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.from([ + 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 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.from([ + (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(); + } +} diff --git a/mobile/lib/utils/debounce.dart b/mobile/lib/utils/debounce.dart new file mode 100644 index 0000000000..273ee8ba95 --- /dev/null +++ b/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; + } +} diff --git a/mobile/lib/utils/flutter_map_extensions.dart b/mobile/lib/utils/flutter_map_extensions.dart new file mode 100644 index 0000000000..4fc812b4a7 --- /dev/null +++ b/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 _rotatePoint( + CustomPoint mapCenter, + CustomPoint 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; + } +} diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 3fe68f131d..2056237cbd 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/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, }) { diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart new file mode 100644 index 0000000000..08de7961b6 --- /dev/null +++ b/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 selection, +) { + showDialog( + context: context, + builder: (BuildContext buildContext) { + ref + .watch(shareServiceProvider) + .shareAssets(selection.toList()) + .then((_) => Navigator.of(buildContext).pop()); + return const ShareDialog(); + }, + barrierDismissible: false, + ); +} + +Future handleArchiveAssets( + WidgetRef ref, + BuildContext context, + List 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 handleFavoriteAssets( + WidgetRef ref, + BuildContext context, + List 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, + ); + } + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 811c62da2c..03d7f020c9 100644 --- a/mobile/pubspec.lock +++ b/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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a50e3d269c..1bb5f37384 100644 --- a/mobile/pubspec.yaml +++ b/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