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