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 <joshua.herrera227@gmail.com> --- 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 | Bin 913 -> 913 bytes .../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 | Bin 0 -> 898 bytes .../map/providers/map_service.provider.dart | 9 + .../map/providers/map_service.provider.g.dart | Bin 0 -> 877 bytes .../map/providers/map_state.provider.dart | 229 +++-- .../map/providers/map_state.provider.g.dart | Bin 0 -> 933 bytes .../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 | Bin 907 -> 907 bytes .../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, 2782 insertions(+), 2169 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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 Mamf6E89DL)02~a<FaQ7m literal 50753 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 a8ae&HKadlv3N9HV5K`iDV)-IEUjGMx6B*qA diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 75168ce1c9..24a209cec2 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -28,6 +28,10 @@ PODS: - Flutter - isar_flutter_libs (1.0.0): - Flutter + - MapLibre (5.14.0-pre3) + - maplibre_gl (0.0.1): + - Flutter + - MapLibre (= 5.14.0-pre3) - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -71,6 +75,7 @@ DEPENDENCIES: - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) + - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) @@ -86,6 +91,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - MapLibre - ReachabilitySwift - SAMKeychain - Toast @@ -115,6 +121,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" isar_flutter_libs: :path: ".symlinks/plugins/isar_flutter_libs/ios" + maplibre_gl: + :path: ".symlinks/plugins/maplibre_gl/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -152,6 +160,8 @@ SPEC CHECKSUMS: image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 integration_test: 13825b8a9334a850581300559b8839134b124670 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 + MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef + maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 9f08ba3efb..c03477cf43 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -96,3 +96,9 @@ extension AssetListExtension on Iterable<Asset> { return this; } } + +extension SortedByProperty<T> on Iterable<T> { + Iterable<T> sortedByField(Comparable Function(T e) key) { + return sorted((a, b) => key(a).compareTo(key(b))); + } +} 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<double> _rotatePoint( - CustomPoint<double> mapCenter, - CustomPoint<double> point, { - bool counterRotation = true, - }) { - final counterRotationFactor = counterRotation ? -1 : 1; - - final m = Matrix4.identity() - ..translate(mapCenter.x, mapCenter.y) - ..rotateZ(degToRadian(rotation) * counterRotationFactor) - ..translate(-mapCenter.x, -mapCenter.y); - - final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y)); - - return CustomPoint(tp.dx, tp.dy); - } - - double getTapThresholdForZoomLevel() { - const scale = [ - 25000000, - 15000000, - 8000000, - 4000000, - 2000000, - 1000000, - 500000, - 250000, - 100000, - 50000, - 25000, - 15000, - 8000, - 4000, - 2000, - 1000, - 500, - 250, - 100, - 50, - 25, - 10, - 5, - ]; - return scale[math.max(0, math.min(20, zoom.round() + 2))].toDouble() / 6; - } -} 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<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async { + return addSource( + MapUtils.defaultSourceId, + GeojsonSourceProperties( + data: MapUtils.generateGeoJsonForMarkers(markers.toList()), + ), + ); + } + + Future<void> reloadAllLayersForMarkers(List<MapMarker> markers) async { + // !! Make sure to remove layers before sources else the native + // maplibre library would crash when removing the source saying that + // the source is still in use + final existingLayers = await getLayerIds(); + if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) { + await removeLayer(MapUtils.defaultHeatMapLayerId); + } + + final existingSources = await getSourceIds(); + if (existingSources.contains(MapUtils.defaultSourceId)) { + await removeSource(MapUtils.defaultSourceId); + } + + await addGeoJSONSourceForMarkers(markers); + + await addHeatmapLayer( + MapUtils.defaultSourceId, + MapUtils.defaultHeatMapLayerId, + MapUtils.defaultHeatMapLayerProperties, + ); + } + + Future<Symbol?> addMarkerAtLatLng(LatLng centre) async { + // no marker is displayed if asset-path is incorrect + try { + final ByteData bytes = await rootBundle.load("assets/location-pin.png"); + await addImage("mapMarker", bytes.buffer.asUint8List()); + return addSymbol( + SymbolOptions( + geometry: centre, + iconImage: "mapMarker", + iconSize: 0.15, + iconAnchor: "bottom", + ), + ); + } finally { + // no-op + } + } + + Future<LatLngBounds> getBoundsFromPoint( + Point<double> point, + double distance, + ) async { + final southWestPx = Point(point.x - distance, point.y + distance); + final northEastPx = Point(point.x + distance, point.y - distance); + + final southWest = await toLatLng(southWestPx); + final northEast = await toLatLng(northEastPx); + + return LatLngBounds(southwest: southWest, northeast: northEast); + } +} 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 53daa74a12667ae3f5248fc24392fe877ab37ed0..96628dab5897390c57fa0c92bdbea6039511ff0f 100644 GIT binary patch delta 53 zcmbQpK9PNc8l#3$N@|*+L29C@rHP@bQL?3lNn)yzrMXFpnT2^`l9`36S&Dg5N{Yc| IKSpOJ0E)j3cK`qY delta 53 zcmbQpK9PNc8l#4Rp+$;inx(OsrKMrAiA9=$S+b#FQkr?Hxsjo<K~hqpk*S4&S*p=y IKSpOJ0D5Q*+5i9m 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<ItemPosition> itemPositions)? visibleItemsListener; final Widget? topWidget; final bool shrinkWrap; @@ -89,8 +89,10 @@ class ImmichAssetGrid extends HookConsumerWidget { }; scale.onUpdate = (details) { - scaleFactor.value = - max(min(5.0, baseScaleFactor.value * details.scale), 1.0); + scaleFactor.value = max( + min(5.0, baseScaleFactor.value * details.scale), + 1.0, + ); if (7 - scaleFactor.value.toInt() != perRow.value) { perRow.value = 7 - scaleFactor.value.toInt(); } 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<ItemPosition> itemPositions)? visibleItemsListener; final Widget? topWidget; final int heroOffset; @@ -421,15 +421,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { void _positionListener() { final values = _itemPositionsListener.itemPositions.value; - final start = values.firstOrNull; - final end = values.lastOrNull; - if (start != null && end != null) { - if (start.index <= end.index) { - widget.visibleItemsListener?.call(start, end); - } else { - widget.visibleItemsListener?.call(end, start); - } - } + widget.visibleItemsListener?.call(values); } void _scrollToTop() { 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<String> 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<Asset> assets; - MapPageAssetsInBoundUpdated(this.assets) - : super(MapPageEventType.assetsInBoundUpdated); -} - -class MapPageBottomSheetScrolled extends MapPageEventBase { - Asset? asset; - MapPageBottomSheetScrolled(this.asset) - : super(MapPageEventType.bottomSheetScrolled); -} - -class MapPageZoomToAsset extends MapPageEventBase { - Asset? asset; - MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset); -} - -class MapPageZoomToLocation extends MapPageEventBase { - const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation); -} 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<String> lightStyleFetched; + final AsyncValue<String> darkStyleFetched; MapState({ - this.isDarkTheme = false, + this.themeMode = ThemeMode.system, this.showFavoriteOnly = false, this.includeArchived = false, this.relativeTime = 0, - this.mapStyle, - this.isLoading = false, + this.shouldRefetchMarkers = false, + this.lightStyleFetched = const AsyncLoading(), + this.darkStyleFetched = const AsyncLoading(), }); MapState copyWith({ - bool? isDarkTheme, + ThemeMode? themeMode, bool? showFavoriteOnly, bool? includeArchived, int? relativeTime, - Style? mapStyle, - bool? isLoading, + bool? shouldRefetchMarkers, + AsyncValue<String>? lightStyleFetched, + AsyncValue<String>? darkStyleFetched, }) { return MapState( - isDarkTheme: isDarkTheme ?? this.isDarkTheme, + themeMode: themeMode ?? this.themeMode, showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, includeArchived: includeArchived ?? this.includeArchived, relativeTime: relativeTime ?? this.relativeTime, - mapStyle: mapStyle ?? this.mapStyle, - isLoading: isLoading ?? this.isLoading, + shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers, + lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched, + darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched, ); } @override String toString() { - return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)'; + return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)'; } @override - bool operator ==(Object other) { + bool operator ==(covariant MapState other) { if (identical(this, other)) return true; - return other is MapState && - other.isDarkTheme == isDarkTheme && + return other.themeMode == themeMode && other.showFavoriteOnly == showFavoriteOnly && - other.relativeTime == relativeTime && other.includeArchived == includeArchived && - other.mapStyle == mapStyle && - other.isLoading == isLoading; + other.relativeTime == relativeTime && + other.shouldRefetchMarkers == shouldRefetchMarkers && + other.lightStyleFetched == lightStyleFetched && + other.darkStyleFetched == darkStyleFetched; } @override int get hashCode { - return isDarkTheme.hashCode ^ + return themeMode.hashCode ^ showFavoriteOnly.hashCode ^ - relativeTime.hashCode ^ includeArchived.hashCode ^ - mapStyle.hashCode ^ - isLoading.hashCode; + relativeTime.hashCode ^ + shouldRefetchMarkers.hashCode ^ + lightStyleFetched.hashCode ^ + darkStyleFetched.hashCode; } } 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<Set<AssetMarkerData>>((ref) async { +part 'map_marker.provider.g.dart'; + +@riverpod +Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async { final service = ref.read(mapServiceProvider); - final mapState = ref.read(mapStateNotifier); + final mapState = ref.read(mapStateNotifierProvider); DateTime? fileCreatedAfter; bool? isFavorite; bool? isIncludeArchived; @@ -31,34 +32,5 @@ final mapMarkersProvider = fileCreatedAfter: fileCreatedAfter, ); - final assetMarkerData = await Future.wait( - markers.map((e) async { - final asset = await service.getAssetForMarkerId(e.id); - bool hasInvalidCoords = e.lat < -90 || e.lat > 90; - hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180); - if (asset == null || hasInvalidCoords) return null; - return AssetMarkerData(asset, LatLng(e.lat, e.lon)); - }), - ); - - return assetMarkerData.nonNulls.toSet(); -}); - -class AssetMarkerData { - final LatLng point; - final Asset asset; - - const AssetMarkerData(this.asset, this.point); - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetMarkerData && other.asset.remoteId == asset.remoteId; - } - - @override - int get hashCode { - return asset.remoteId.hashCode; - } + return markers.toList(); } 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 0000000000000000000000000000000000000000..7df6adea9941a769cf0702c5baff327d80f32c70 GIT binary patch literal 898 zcmb_a!H(K65WVLs=1?h6Z3)#jWnmXux(RHh1uEdOs;bC|C)BEAM`I^a|GqXXOQlpj ztxq1mr#F6YCW>I0uG8&&pC)j>Nm6(R$p+S&J*+m#*ToZjd4h-eIthZBkp*2sSg|^* z82v&T)TCR%-Gw_aybXdVg14`ByxLZ@NVVomR7lKfdgT_u&XQ0E$lf?xtD`IPz|7BH zAEq;)a6HV1!+ccU#6|oee?KnckHv_`F~4T~CLYDbaLieLU7XBEutNkUjfU?Ry&r?% z^S9@u7Mgk&q#u+*F$tHKGY_UPZ>&y)skOmHV;jOVcK0ZZy<46CJDUxJvPe#-7r<Xy zSFTd5!in?I?GSpHW8NH=XzvM`#hq@bKyQEIFn<cIjD=ilIVee2scHpjRiU!I(9e9( zR(=V1Q`oQ%pFlNI!laYtsa1`NqbdZN$&u(^nUs5C$_VQQonV*JcUxtD*T}Ji7t3br zs{bV9;7&-lesjb@X~L}3l$AodWW4lLy8Ql$nkM&zOqZEKCXr`_WX4>9P%V>!XN@_6 a=lrt@s~o9=x59{Ap88re)@|G~HTVOz+8Nyd literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7b4e68eaee3b9ba77ae4858113547a246095ed39 GIT binary patch literal 877 zcmb_aL2lbH5WMRZdnh2gNaZ$3?Zm0mx{>1a5WBE_35r6X$fZkwB84R-AipnZ#c%-U z7~Z%u+~v&9XatL5S*+*VB8R(GUce{FSFl`d;c=D!x_^S7Pw+5b=1I~p@?a&THET+T z)G3ZxL$*@_DH8!rZ<1s*f{*WZVz;&GkQyr%Xpoq<^v)xajVEPxP<{xyHodF|=Kc+a zFuMgx&*k`vf1WTt=He@#Tq3`!@S+muLSBE7m!cX^E~?2j_TopdK?J6qg+IrBf3xJr z?-!@;Ee(#yFp<hI4adrX2Q!$r-sZ|R*5RT0?Q#D})p5Mdl<`R6^&o*)4bH;Nuo|b~ z^QUdo2n(#*-2(j`A@jJgEpd#RrvZzHTjM-b*6K`>tqapBvZhAk2Web%*41(IqUAmv z!gnyO)-XM>upiZ+5oowVH{BCsm1(^tW}Nb2yE&dxh9OG+xj}&vddV?v>u`J1mH@nV z<|bxD?TjTXrKM6T9qfqxW;&SmChFQM5=tvWxS2*#a?PAO17$j<l_*=c2M?x~HP#i< T3EnBED&5cZ=zPf53pM!<j${(E literal 0 HcmV?d00001 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<MapState> { - MapStateNotifier(this._appSettingsProvider, this._apiService) - : super( - MapState( - isDarkTheme: _appSettingsProvider - .getSetting<bool>(AppSettingsEnum.mapThemeMode), - showFavoriteOnly: _appSettingsProvider - .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly), - includeArchived: _appSettingsProvider - .getSetting<bool>(AppSettingsEnum.mapIncludeArchived), - relativeTime: _appSettingsProvider - .getSetting<int>(AppSettingsEnum.mapRelativeDate), - isLoading: true, - ), - ) { - _fetchStyleFromServer( - _appSettingsProvider.getSetting<bool>(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<int>(AppSettingsEnum.mapThemeMode)], + showFavoriteOnly: appSettingsProvider + .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly), + includeArchived: appSettingsProvider + .getSetting<bool>(AppSettingsEnum.mapIncludeArchived), + relativeTime: + appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate), ); } - final AppSettingsService _appSettingsProvider; - final ApiService _apiService; - final Logger _log = Logger("MapStateNotifier"); + void loadStyles() async { + final documents = (await getApplicationDocumentsDirectory()).path; - bool get isRaster => - state.mapStyle != null && state.mapStyle!.rasterTileProvider != null; + // Set to loading + state = state.copyWith(lightStyleFetched: const AsyncLoading()); - double get maxZoom => - (isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 18) - .toDouble(); + // Fetch and save light theme + final lightResponse = await ref + .read(apiServiceProvider) + .systemConfigApi + .getMapStyleWithHttpInfo(MapTheme.light); - void switchTheme(bool isDarkTheme) { - _updateThemeMode(isDarkTheme); - _fetchStyleFromServer(isDarkTheme); - } - - void _updateThemeMode(bool isDarkTheme) { - _appSettingsProvider.setSetting( - AppSettingsEnum.mapThemeMode, - isDarkTheme, - ); - state = state.copyWith(isDarkTheme: isDarkTheme, isLoading: true); - } - - void _fetchStyleFromServer(bool isDarkTheme) async { - final styleResponse = await _apiService.systemConfigApi - .getMapStyleWithHttpInfo(isDarkTheme ? MapTheme.dark : MapTheme.light); - if (styleResponse.statusCode >= HttpStatus.badRequest) { - throw ApiException(styleResponse.statusCode, styleResponse.body); - } - final styleJsonString = styleResponse.body.isNotEmpty && - styleResponse.statusCode != HttpStatus.noContent - ? styleResponse.body - : null; - - if (styleJsonString == null) { - _log.severe('Style JSON from server is empty'); + 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<String, dynamic>) { - _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<MapStateNotifier, MapState>((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 0000000000000000000000000000000000000000..ca75292e7892745a1c35f420ca38d7bc2fce6e39 GIT binary patch literal 933 zcmb_aL2lbH5WMRZdnh2ga3v?L;mE1orjg?G5W9$d35r6X#Fa~cB84TTqQ5U`TRAAr zExd7Oca}4pIEHn$$sTX^Sqit?G=mS2Zeg?C!^1ZHe)k05p5XpwlLkS<$bzmQtXY#A z%Pd9>=}vH@h&wF24uUv__iuJQ+oR}^8qL?JkeJo<#w&uIC7}+GzjLZ~UercgQ3<4b zW`2*xuv`I!AB)*EDQ5Fx{%JZbrpeXC9FvkECLD|EDoHNpmnENFo-BxAhX_m>4L=_S ztiPh*>yNW*cbY~+d>m9lF$u%h^fRywz@7e)efjf{X99g?6$xdL9O=l7_SK#sQut@$ z>WbC4a7tg>8~P?Ewmq!T-V!>acDkh!y-f=!wK5h8tz}e^t~1pM(yB&fN1@L~)YU#L zye(}wJ}LnC0;-krd|ABm1E;kODvqiYXcosIUu9D6i76wji%5zuTgEPr_HTn6D@Q&Q z4$Ef~pUqoc`*ewe(u8@XDX)Zdu6UBAbT0lEHErP=a$V&HnM9tKk{L4rp*ki7&s%c@ a`(MawtP7+Ucqfb~<Y})(V_hoGt-(J)7bHjk literal 0 HcmV?d00001 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<List<MapMarkerResponseDto>> getMapMarkers({ + Future<Iterable<MapMarker>> 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<Asset?> getAssetForMarkerId(String remoteId) async { - try { - final assets = await _db.assets.getAllByRemoteId([remoteId]); - if (assets.isNotEmpty) return assets[0]; - - final dto = await _apiService.assetApi.getAssetById(remoteId); - if (dto == null) return null; - return _db.assets.getByRemoteId(dto.id); - } catch (error, stack) { - _log.severe( - "Cannot get asset for marker ${error.toString()}", - error, - stack, - ); - return 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<LatLng>(initialLatLng ?? LatLng(0, 0)); - final isDarkTheme = - ref.watch(mapStateNotifier.select((state) => state.isDarkTheme)); - final isLoading = - ref.watch(mapStateNotifier.select((state) => state.isLoading)); - final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom; - - return Theme( - // Override app theme based on map theme - data: isDarkTheme ? immichDarkTheme : immichLightTheme, - child: Scaffold( - extendBodyBehindAppBar: true, - body: Stack( - children: [ - if (!isLoading) - FlutterMap( - options: MapOptions( - maxBounds: - LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)), - interactiveFlags: InteractiveFlag.doubleTapZoom | - InteractiveFlag.drag | - InteractiveFlag.flingAnimation | - InteractiveFlag.pinchMove | - InteractiveFlag.pinchZoom, - center: LatLng(20, 20), - zoom: 2, - minZoom: 1, - maxZoom: maxZoom, - onTap: (tapPosition, point) => selectedLatLng.value = point, - ), - children: [ - ref.read(mapStateNotifier.notifier).getTileLayer(), - MarkerLayer( - markers: [ - Marker( - anchorPos: AnchorPos.align(AnchorAlign.top), - point: selectedLatLng.value, - builder: (ctx) => const Image( - image: AssetImage('assets/location-pin.png'), - ), - height: 40, - width: 40, - ), - ], - ), - ], - ), - if (isLoading) - Positioned( - top: context.height * 0.35, - left: context.width * 0.425, - child: const ImmichLoadingIndicator(), - ), - ], - ), - bottomSheet: BottomSheet( - onClosing: () {}, - builder: (context) => SizedBox( - height: 150, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}", - style: context.textTheme.bodyLarge?.copyWith( - color: context.primaryColor, - fontWeight: FontWeight.w600, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: () => context.popRoute(selectedLatLng.value), - child: const Text("map_location_picker_page_use_location") - .tr(), - ), - ElevatedButton( - onPressed: () => context.popRoute(), - style: ElevatedButton.styleFrom( - backgroundColor: context.colorScheme.error, - ), - child: const Text("action_common_cancel").tr(), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} 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<bool> selectionEnabled; - final int selectedAssetsLength; - final bool isDarkTheme; - - final void Function() onShare; - final void Function() onFavorite; - final void Function() onArchive; - - const MapAppBar({ - super.key, - required this.selectionEnabled, - required this.selectedAssetsLength, - required this.onShare, - required this.onArchive, - required this.onFavorite, - this.isDarkTheme = false, - }); - - List<Widget> buildNonSelectionWidgets(BuildContext context) { - return [ - Padding( - padding: const EdgeInsets.only(left: 15, top: 15), - child: ElevatedButton( - onPressed: () => context.popRoute(), - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 15, top: 15), - child: ElevatedButton( - onPressed: () => showDialog( - context: context, - builder: (BuildContext _) { - return const MapSettingsDialog(); - }, - ), - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: const Icon(Icons.more_vert_rounded, size: 22), - ), - ), - ]; - } - - List<Widget> buildSelectionWidgets() { - return [ - DisableMultiSelectButton( - onPressed: () { - selectionEnabled.value = false; - }, - selectedItemCount: selectedAssetsLength, - ), - Row( - children: [ - // Share button - Padding( - padding: const EdgeInsets.only(top: 15), - child: ElevatedButton( - onPressed: onShare, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: Icon( - Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, - size: 22, - ), - ), - ), - // Favorite button - Padding( - padding: const EdgeInsets.only(top: 15), - child: ElevatedButton( - onPressed: onFavorite, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: const Icon( - Icons.favorite, - size: 22, - ), - ), - ), - // Archive Button - Padding( - padding: const EdgeInsets.only(right: 10, top: 15), - child: ElevatedButton( - onPressed: onArchive, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: const Icon( - Icons.archive, - size: 22, - ), - ), - ), - ], - ), - ]; - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (!selectionEnabled.value) ...buildNonSelectionWidgets(context), - if (selectionEnabled.value) ...buildSelectionWidgets(), - ], - ), - ); - } - - @override - Size get preferredSize => const Size.fromHeight(100); -} 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<MapPageBottomSheet> { - // Non-State variables - bool userTappedOnMap = false; - RenderList? _cachedRenderList; - int assetOffsetInSheet = -1; - late final DraggableScrollableController bottomSheetController; - late final Debounce debounce; - - @override - void initState() { - super.initState(); - bottomSheetController = DraggableScrollableController(); - debounce = Debounce( - const Duration(milliseconds: 100), - ); - } - - @override - Widget build(BuildContext context) { - final isDarkTheme = context.isDarkTheme; - final bottomPadding = - Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0; - final maxHeight = context.height - bottomPadding; - final isSheetScrolled = useState(false); - final isSheetExpanded = useState(false); - final assetsInBound = useState(<Asset>[]); - final currentExtend = useState(0.1); - - void handleMapPageEvents(dynamic event) { - if (event is MapPageAssetsInBoundUpdated) { - assetsInBound.value = event.assets; - } else if (event is MapPageOnTapEvent) { - userTappedOnMap = true; - assetOffsetInSheet = -1; - bottomSheetController.animateTo( - 0.1, - duration: const Duration(milliseconds: 200), - curve: Curves.linearToEaseOut, - ); - isSheetScrolled.value = false; - } - } - - useEffect( - () { - final mapPageEventSubscription = - widget.mapPageEventStream.listen(handleMapPageEvents); - return mapPageEventSubscription.cancel; - }, - [widget.mapPageEventStream], - ); - - void handleVisibleItems(ItemPosition start, ItemPosition end) { - final renderElement = _cachedRenderList?.elements[start.index]; - if (renderElement == null) { - return; - } - final rowOffset = renderElement.offset; - if ((-start.itemLeadingEdge) != 0) { - var columnOffset = -start.itemLeadingEdge ~/ 0.05; - columnOffset = columnOffset < renderElement.totalCount - ? columnOffset - : renderElement.totalCount - 1; - assetOffsetInSheet = rowOffset + columnOffset; - final asset = _cachedRenderList?.allAssets?[assetOffsetInSheet]; - userTappedOnMap = false; - if (!userTappedOnMap && isSheetExpanded.value) { - widget.bottomSheetEventSC.add( - MapPageBottomSheetScrolled(asset), - ); - } - if (isSheetExpanded.value) { - isSheetScrolled.value = true; - } - } - } - - void visibleItemsListener(ItemPosition start, ItemPosition end) { - if (_cachedRenderList == null) { - debounce.dispose(); - return; - } - debounce.call(() => handleVisibleItems(start, end)); - } - - Widget buildNoPhotosWidget() { - const image = Image( - image: AssetImage('assets/lighthouse.png'), - ); - - return isSheetExpanded.value - ? Column( - children: [ - const SizedBox( - height: 80, - ), - SizedBox( - height: 150, - width: 150, - child: isDarkTheme - ? const InvertionFilter( - child: SaturationFilter( - saturation: -1, - child: BrightnessFilter( - brightness: -5, - child: image, - ), - ), - ) - : image, - ), - const SizedBox( - height: 20, - ), - Text( - "map_zoom_to_see_photos".tr(), - style: TextStyle( - fontSize: 20, - color: context.textTheme.displayLarge?.color, - ), - ), - ], - ) - : const SizedBox.shrink(); - } - - void onTapMapButton() { - if (assetOffsetInSheet != -1) { - widget.bottomSheetEventSC.add( - MapPageZoomToAsset( - _cachedRenderList?.allAssets?[assetOffsetInSheet], - ), - ); - } - } - - Widget buildDragHandle(ScrollController scrollController) { - final textToDisplay = assetsInBound.value.isNotEmpty - ? "map_assets_in_bounds" - .tr(args: [assetsInBound.value.length.toString()]) - : "map_no_assets_in_bounds".tr(); - final dragHandle = Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[900] : Colors.grey[100], - ), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 5), - const CustomDraggingHandle(), - const SizedBox(height: 15), - Text( - textToDisplay, - style: context.textTheme.bodyLarge, - ), - Divider( - height: 10, - color: - context.textTheme.displayLarge?.color?.withOpacity(0.5), - ), - ], - ), - if (isSheetExpanded.value && isSheetScrolled.value) - Positioned( - top: 5, - right: 10, - child: IconButton( - icon: Icon( - Icons.map_outlined, - color: context.textTheme.displayLarge?.color, - ), - iconSize: 20, - tooltip: 'Zoom to bounds', - onPressed: onTapMapButton, - ), - ), - ], - ), - ); - return SingleChildScrollView( - controller: scrollController, - physics: const ClampingScrollPhysics(), - child: dragHandle, - ); - } - - return NotificationListener<DraggableScrollableNotification>( - onNotification: (DraggableScrollableNotification notification) { - final sheetExtended = notification.extent > 0.2; - isSheetExpanded.value = sheetExtended; - currentExtend.value = notification.extent; - if (!sheetExtended) { - // reset state - userTappedOnMap = false; - assetOffsetInSheet = -1; - isSheetScrolled.value = false; - } - - return true; - }, - child: Padding( - padding: EdgeInsets.only( - bottom: bottomPadding, - ), - child: Stack( - children: [ - DraggableScrollableSheet( - controller: bottomSheetController, - initialChildSize: 0.1, - minChildSize: 0.1, - maxChildSize: 0.55, - snap: true, - builder: ( - BuildContext context, - ScrollController scrollController, - ) { - return Card( - color: isDarkTheme ? Colors.grey[900] : Colors.grey[100], - surfaceTintColor: Colors.transparent, - elevation: 18.0, - margin: const EdgeInsets.all(0), - child: Column( - children: [ - buildDragHandle(scrollController), - if (isSheetExpanded.value && - assetsInBound.value.isNotEmpty) - ref - .watch( - renderListProvider( - assetsInBound.value, - ), - ) - .when( - data: (renderList) { - _cachedRenderList = renderList; - final assetGrid = ImmichAssetGrid( - shrinkWrap: true, - renderList: renderList, - showDragScroll: false, - selectionActive: widget.selectionEnabled, - showMultiSelectIndicator: false, - listener: widget.selectionlistener, - visibleItemsListener: visibleItemsListener, - ); - - return Expanded(child: assetGrid); - }, - error: (error, stackTrace) { - log.warning( - "Cannot get assets in the current map bounds ${error.toString()}", - error, - stackTrace, - ); - return const SizedBox.shrink(); - }, - loading: () => const SizedBox.shrink(), - ), - if (isSheetExpanded.value && assetsInBound.value.isEmpty) - Expanded( - child: SingleChildScrollView( - child: buildNoPhotosWidget(), - ), - ), - ], - ), - ); - }, - ), - Positioned( - bottom: maxHeight * currentExtend.value, - left: 0, - child: ColoredBox( - color: - (widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!, - child: Padding( - padding: const EdgeInsets.all(3), - child: Text( - 'OpenStreetMap contributors', - style: TextStyle( - fontSize: 6, - color: !widget.isDarkTheme - ? Colors.grey[900] - : Colors.grey[100], - ), - ), - ), - ), - ), - Positioned( - bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)), - right: 15, - child: ElevatedButton( - onPressed: () => widget.bottomSheetEventSC - .add(const MapPageZoomToLocation()), - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(12), - ), - child: const Icon( - Icons.my_location, - size: 22, - fill: 1, - ), - ), - ), - ], - ), - ), - ); - } -} 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<Widget> getDialogActions() { - return <Widget>[ - TextButton( - onPressed: () => context.pop(), - style: TextButton.styleFrom( - backgroundColor: - mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700], - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - "map_settings_dialog_cancel".tr(), - style: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, - color: mapSettings.isDarkTheme - ? Colors.grey[900] - : Colors.grey[100], - ), - ), - ), - ), - TextButton( - onPressed: () { - mapSettingsNotifier.switchTheme(isDarkMode.value); - mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value); - mapSettingsNotifier.setRelativeTime(showRelativeDate.value); - mapSettingsNotifier - .switchIncludeArchived(showIncludeArchived.value); - context.pop(); - }, - style: TextButton.styleFrom( - backgroundColor: theme.primaryColor, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - "map_settings_dialog_save".tr(), - style: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, - color: theme.primaryTextTheme.labelLarge?.color, - ), - ), - ), - ), - ]; - } - - return AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - title: Center( - child: Text( - "map_settings_dialog_title".tr(), - style: TextStyle( - color: theme.primaryColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - content: SizedBox( - width: double.maxFinite, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: context.height * 0.6, - ), - child: ListView( - shrinkWrap: true, - children: [ - buildMapThemeSetting(), - buildFavoriteOnlySetting(), - buildIncludeArchivedSetting(), - const SizedBox( - height: 10, - ), - Padding( - padding: const EdgeInsets.only(left: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "map_settings_only_relative_range".tr(), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - buildDateRangeSetting(), - ], - ), - ), - ].toList(), - ), - ), - ), - actions: getDialogActions(), - actionsAlignment: MainAxisAlignment.spaceEvenly, - ); - } -} 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<Marker> markers; - final double height; - final double width; - final bool showAttribution; - final bool isDarkTheme; - - const MapThumbnail({ - super.key, - required this.coords, - this.height = 100, - this.width = 100, - this.onTap, - this.zoom = 1, - this.showAttribution = true, - this.isDarkTheme = false, - this.markers = const [], - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final mapController = useMapController(); - final isMapReady = useRef(false); - ref.watch(mapStateNotifier.select((s) => s.mapStyle)); - - useEffect( - () { - if (isMapReady.value && mapController.center != coords) { - mapController.move(coords, zoom); - } - return null; - }, - [coords], - ); - - return SizedBox( - height: height, - width: width, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(15)), - child: FlutterMap( - mapController: mapController, - options: MapOptions( - interactiveFlags: InteractiveFlag.none, - center: coords, - zoom: zoom, - onTap: onTap, - onMapReady: () => isMapReady.value = true, - ), - nonRotatedChildren: [ - if (showAttribution) - RichAttributionWidget( - animationConfig: const ScaleRAWA(), - attributions: [ - TextSourceAttribution( - 'OpenStreetMap contributors', - onTap: () => launchUrl( - Uri.parse('https://openstreetmap.org/copyright'), - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ], - children: [ - ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme), - if (markers.isNotEmpty) MarkerLayer(markers: markers), - ], - ), - ), - ); - } -} 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<Object?>? keys, -}) { - return use(_MapControllerHook(keys: keys)); -} - -class _MapControllerHook extends Hook<MapController> { - const _MapControllerHook({List<Object?>? keys}) : super(keys: keys); - - @override - HookState<MapController, Hook<MapController>> createState() => - _MapControllerHookState(); -} - -class _MapControllerHookState - extends HookState<MapController, _MapControllerHook> { - late final controller = MapController(); - - @override - MapController build(BuildContext context) => controller; - - @override - void dispose() => controller.dispose(); - - @override - String get debugLabel => 'useMapController'; -} 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<String, dynamic> _addFeature(MapMarker marker) => { + 'type': 'Feature', + 'id': marker.assetRemoteId, + 'geometry': { + 'type': 'Point', + 'coordinates': [marker.latLng.longitude, marker.latLng.latitude], + }, + }; + + static Map<String, dynamic> generateGeoJsonForMarkers( + List<MapMarker> markers, + ) => + { + 'type': 'FeatureCollection', + 'features': markers.map(_addFeature).toList(), + }; + + static Future<(Position?, LocationPermission?)> checkPermAndGetLocation( + BuildContext context, + ) async { + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + showDialog( + context: context, + builder: (context) => _LocationServiceDisabledDialog(), + ); + return (null, LocationPermission.deniedForever); + } + + LocationPermission permission = await Geolocator.checkPermission(); + bool shouldRequestPermission = false; + + if (permission == LocationPermission.denied) { + shouldRequestPermission = await showDialog( + context: context, + builder: (context) => _LocationPermissionDisabledDialog(), + ); + if (shouldRequestPermission) { + permission = await Geolocator.requestPermission(); + } + } + + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + // Open app settings only if you did not request for permission before + if (permission == LocationPermission.deniedForever && + !shouldRequestPermission) { + await Geolocator.openAppSettings(); + } + return (null, LocationPermission.deniedForever); + } + + Position currentUserLocation = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 5), + ); + return (currentUserLocation, null); + } catch (error) { + _log.severe( + "Cannot get user's current location due to ${error.toString()}", + ); + return (null, LocationPermission.unableToDetermine); + } + } +} + +class _LocationServiceDisabledDialog extends ConfirmDialog { + _LocationServiceDisabledDialog() + : super( + title: 'map_location_service_disabled_title'.tr(), + content: 'map_location_service_disabled_content'.tr(), + cancel: 'map_location_dialog_cancel'.tr(), + ok: 'map_location_dialog_yes'.tr(), + onOk: () async { + await Geolocator.openLocationSettings(); + }, + ); +} + +class _LocationPermissionDisabledDialog extends ConfirmDialog { + _LocationPermissionDisabledDialog() + : super( + title: 'map_no_location_permission_title'.tr(), + content: 'map_no_location_permission_content'.tr(), + cancel: 'map_location_dialog_cancel'.tr(), + ok: 'map_location_dialog_yes'.tr(), + onOk: () {}, + ); +} 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<LatLng>(initialLatLng); + final controller = useRef<MaplibreMapController?>(null); + final marker = useRef<Symbol?>(null); + + Future<void> onStyleLoaded() async { + marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng); + } + + Future<void> onMapClick(Point<num> point, LatLng centre) async { + selectedLatLng.value = centre; + controller.value?.animateCamera(CameraUpdate.newLatLng(centre)); + if (marker.value != null) { + await controller.value + ?.updateSymbol(marker.value!, SymbolOptions(geometry: centre)); + } + } + + void onClose([LatLng? selected]) { + context.popRoute(selected); + } + + Future<void> getCurrentLocation() async { + var (currentLocation, locationPermission) = await MapUtils.checkPermAndGetLocation(context); + if (locationPermission == LocationPermission.denied || + locationPermission == LocationPermission.deniedForever) { + return; + } + if (currentLocation == null) { + return; + } + var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude); + selectedLatLng.value = currentLatLng; + controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng)); + } + + return MapThemeOveride( + mapBuilder: (style) => Builder( + builder: (ctx) => Scaffold( + backgroundColor: ctx.themeData.cardColor, + appBar: _AppBar(onClose: onClose), + extendBodyBehindAppBar: true, + body: Column( + children: [ + style.widgetWhen( + onData: (style) => Expanded( + child: Container( + clipBehavior: Clip.antiAliasWithSaveLayer, + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(40), + bottomRight: Radius.circular(40), + ), + ), + child: MaplibreMap( + initialCameraPosition: + CameraPosition(target: initialLatLng, zoom: 12), + styleString: style, + onMapCreated: (mapController) => + controller.value = mapController, + onStyleLoadedCallback: onStyleLoaded, + onMapClick: onMapClick, + dragEnabled: false, + tiltGesturesEnabled: false, + myLocationEnabled: false, + attributionButtonMargins: const Point(20, 15), + ), + ), + ), + ), + _BottomBar( + selectedLatLng: selectedLatLng, + onUseLocation: () => onClose(selectedLatLng.value), + onGetCurrentLocation: getCurrentLocation, + ), + ], + ), + ), + ), + ); + } +} + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + final Function() onClose; + + const _AppBar({required this.onClose}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25), + child: Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ElevatedButton( + onPressed: onClose, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.arrow_back_ios_new_rounded), + ), + ), + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(100); +} + +class _BottomBar extends StatelessWidget { + final ValueNotifier<LatLng> selectedLatLng; + final Function() onUseLocation; + final Function() onGetCurrentLocation; + + const _BottomBar({ + required this.selectedLatLng, + required this.onUseLocation, + required this.onGetCurrentLocation, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.public, size: 18), + const SizedBox(width: 15), + ValueListenableBuilder( + valueListenable: selectedLatLng, + builder: (_, value, __) => Text( + "${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}", + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: onUseLocation, + child: const Text("map_location_picker_page_use_location").tr(), + ), + ElevatedButton( + onPressed: onGetCurrentLocation, + child: const Icon(Icons.my_location), + ), + ], + ), + ], + ), + ); + } +} 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<MaplibreMapController?>(null); + final markers = useRef<List<MapMarker>>([]); + final markersInBounds = useRef<List<MapMarker>>([]); + final bottomSheetStreamController = useStreamController<MapEvent>(); + final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null); + final assetsDebouncer = useDebouncer(); + final isLoading = useProcessingOverlay(); + final scrollController = useScrollController(); + final markerDebouncer = + useDebouncer(interval: const Duration(milliseconds: 800)); + final selectedAssets = useValueNotifier<Set<Asset>>({}); + const mapZoomToAssetLevel = 12.0; -class MapPageState extends ConsumerState<MapPage> { - // Non-State variables - late final MapController mapController; - // Streams are used instead of callbacks to prevent unnecessary rebuilds on events - final StreamController mapPageEventSC = - StreamController<MapPageEventBase>.broadcast(); - final StreamController bottomSheetEventSC = - StreamController<MapPageEventBase>.broadcast(); - late final Stream bottomSheetEventStream; - // Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet - // resulting in it getting reloaded each time a map move occurs - Set<AssetMarkerData> assetsInBounds = {}; - // TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded - // https://github.com/fleaflet/flutter_map/issues/1542 - // The below is used instead of MapEventMove#id to handle event from controller - // in onMapEvent() since MapEventMove#id is not populated properly in the - // current version of flutter_map(4.0.0) used - bool forceAssetUpdate = false; - bool isMapReady = false; - late final Debounce debounce; - - @override - void initState() { - super.initState(); - mapController = MapController(); - bottomSheetEventStream = bottomSheetEventSC.stream; - // Map zoom events and move events are triggered often. Throttle the call to limit rebuilds - debounce = Debounce( - const Duration(milliseconds: 300), - ); - } - - @override - void dispose() { - debounce.dispose(); - super.dispose(); - } - - void reloadAssetsInBound( - Set<AssetMarkerData>? assetMarkers, { - bool forceReload = false, - }) { - try { - final bounds = isMapReady ? mapController.bounds : null; - if (bounds != null) { - final oldAssetsInBounds = assetsInBounds.toSet(); - assetsInBounds = - assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {}; - final shouldReload = forceReload || - assetsInBounds.difference(oldAssetsInBounds).isNotEmpty || - assetsInBounds.length != oldAssetsInBounds.length; - if (shouldReload) { - mapPageEventSC.add( - MapPageAssetsInBoundUpdated( - assetsInBounds.map((e) => e.asset).toList(), - ), - ); - } + // updates the markersInBounds value with the map markers that are visible in the current + // map camera bounds + Future<void> updateAssetsInBounds() async { + // Guard map not created + if (mapController.value == null) { + return; } - } 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<void> reloadLayers() async { + if (mapController.value != null) { + mapController.value!.reloadAllLayersForMarkers(markers.value); + } + } + + Future<void> loadMarkers() async { + try { + isLoading.value = true; + markers.value = await ref.read(mapMarkersProvider.future); + assetsDebouncer.run(updateAssetsInBounds); + reloadLayers(); + } finally { + isLoading.value = false; + } + } + + useEffect( + () { + loadMarkers(); + return null; + }, + [], ); - } - @override - Widget build(BuildContext context) { - final log = Logger("MapService"); - final isDarkTheme = - ref.watch(mapStateNotifier.select((state) => state.isDarkTheme)); - final ValueNotifier<Set<AssetMarkerData>> mapMarkerData = - useState(<AssetMarkerData>{}); - final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null); - final selectionEnabledHook = useState(false); - final selectedAssets = useState(<Asset>{}); - final showLoadingIndicator = useState(false); - final refetchMarkers = useState(true); - final isLoading = - ref.watch(mapStateNotifier.select((state) => state.isLoading)); - final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom; - final zoomLevel = math.min(maxZoom, 14.0); - - 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<void> updateAssetMarkerPosition( + MapMarker marker, { + bool shouldAnimate = true, + }) async { + final assetPoint = + await mapController.value!.toScreenLocation(marker.latLng); + selectedMarker.value = _AssetMarkerMeta( + point: assetPoint, + marker: marker, + shouldAnimate: shouldAnimate, + ); + (assetPoint, marker, shouldAnimate); + } + + // finds the nearest asset marker from the tap point and store it as the selectedMarker + Future<void> onMarkerClicked(Point<double> point, LatLng coords) async { + // Guard map not created + if (mapController.value == null) { + return; + } + final latlngBound = + await mapController.value!.getBoundsFromPoint(point, 50); + final marker = markersInBounds.value.firstWhereOrNull( + (m) => + latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)), + ); + + if (marker != null) { + updateAssetMarkerPosition(marker); + } else { + // If no asset was previously selected and no new asset is available, close the bottom sheet + if (selectedMarker.value == null) { + bottomSheetStreamController.add(MapCloseBottomSheet()); } + selectedMarker.value = null; + } + } + + void onMapCreated(MaplibreMapController controller) async { + mapController.value = controller; + controller.addListener(() { + if (controller.isCameraMoving && selectedMarker.value != null) { + updateAssetMarkerPosition( + selectedMarker.value!.marker, + shouldAnimate: false, + ); + } + }); + } + + Future<void> onMarkerTapped() async { + final assetId = selectedMarker.value?.marker.assetRemoteId; + if (assetId == null) { + return; + } + + final asset = await ref.read(dbProvider).assets.getByRemoteId(assetId); + if (asset == null) { + return; + } + + context.pushRoute( + GalleryViewerRoute( + initialIndex: 0, + loadAsset: (index) => asset, + totalAssets: 1, + heroOffset: 0, + ), + ); + } + + /// BOTTOM SHEET CALLBACKS + + Future<void> 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<MapPage> { msg: "map_cannot_get_user_location".tr(), ); } + return; } - } - void handleBottomSheetEvents(dynamic event) { - if (event is MapPageBottomSheetScrolled) { - final assetInBottomSheet = event.asset; - if (assetInBottomSheet != null) { - final mapMarker = mapMarkerData.value - .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id); - closestAssetMarker.value = mapMarker; - if (mapMarker != null && mapController.zoom >= 5) { - LatLng? newCenter = mapController.centerBoundsWithPadding( - mapMarker.point, - const Offset(0, -120), - ); - if (newCenter != null) { - mapController.move( - newCenter, - mapController.zoom, - ); - } - } - } - } 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<Asset> 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<num> 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<Asset> 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<String> style; + final MapCreatedCallback onMapCreated; + final OnCameraIdleCallback onMapMoved; + final OnMapClickCallback onMapClicked; + final OnStyleLoadedCallback onStyleLoaded; + final Function()? onMarkerTapped; + final ValueNotifier<_AssetMarkerMeta?> selectedMarker; + + const _MapWithMarker({ + required this.style, + required this.onMapCreated, + required this.onMapMoved, + required this.onMapClicked, + required this.onStyleLoaded, + required this.selectedMarker, + this.onMarkerTapped, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (ctx, constraints) => SizedBox( + height: constraints.maxHeight, + width: constraints.maxWidth, + child: Stack( + children: [ + style.widgetWhen( + onData: (style) => MaplibreMap( + initialCameraPosition: + const CameraPosition(target: LatLng(0, 0)), + styleString: style, + // This is needed to update the selectedMarker's position on map camera updates + // The changes are notified through the mapController ValueListener which is added in [onMapCreated] + trackCameraPosition: true, + onMapCreated: onMapCreated, + onCameraIdle: onMapMoved, + onMapClick: onMapClicked, + onStyleLoadedCallback: onStyleLoaded, + tiltGesturesEnabled: false, + dragEnabled: false, + myLocationEnabled: false, + attributionButtonPosition: AttributionButtonPosition.TopRight, ), ), - ), - ], - ); - - 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<SystemUiOverlayStyle>( - value: SystemUiOverlayStyle( - statusBarColor: - (isDarkTheme ? Colors.black : Colors.white).withOpacity(0.5), - statusBarIconBrightness: - isDarkTheme ? Brightness.light : Brightness.dark, - systemNavigationBarColor: - isDarkTheme ? Colors.grey[900] : Colors.grey[100], - systemNavigationBarIconBrightness: - isDarkTheme ? Brightness.light : Brightness.dark, - systemNavigationBarDividerColor: Colors.transparent, - ), - child: Theme( - // Override app theme based on map theme - data: isDarkTheme ? immichDarkTheme : immichLightTheme, - child: Scaffold( - appBar: MapAppBar( - isDarkTheme: isDarkTheme, - selectionEnabled: selectionEnabledHook, - selectedAssetsLength: selectedAssets.value.length, - onShare: onShareAsset, - onArchive: onArchiveAsset, - onFavorite: onFavoriteAsset, - ), - 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<Set<Asset>> selectedAssets; + + const MapAppBar({super.key, required this.selectedAssets}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25), + child: ValueListenableBuilder( + valueListenable: selectedAssets, + builder: (ctx, value, child) => value.isNotEmpty + ? _SelectionRow(selectedAssets: selectedAssets) + : _NonSelectionRow(), + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(100); +} + +class _NonSelectionRow extends StatelessWidget { + @override + Widget build(BuildContext context) { + void onSettingsPressed() { + showModalBottomSheet( + elevation: 0.0, + showDragHandle: true, + isScrollControlled: true, + context: context, + builder: (_) => const MapSettingsSheet(), + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: () => context.popRoute(), + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.arrow_back_ios_new_rounded), + ), + ElevatedButton( + onPressed: onSettingsPressed, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.more_vert_rounded), + ), + ], + ); + } +} + +class _SelectionRow extends HookConsumerWidget { + final ValueNotifier<Set<Asset>> selectedAssets; + + const _SelectionRow({required this.selectedAssets}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isProcessing = useProcessingOverlay(); + + Future<void> handleProcessing( + FutureOr<void> Function() action, [ + bool reloadMarkers = false, + ]) async { + isProcessing.value = true; + await action(); + // Reset state + selectedAssets.value = {}; + isProcessing.value = false; + if (reloadMarkers) { + ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(true); + } + } + + return Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 20), + child: ElevatedButton.icon( + onPressed: () => selectedAssets.value = {}, + icon: const Icon(Icons.close_rounded), + label: Text( + '${selectedAssets.value.length}', + style: context.textTheme.titleMedium?.copyWith( + color: context.colorScheme.onPrimary, + ), + ), + ), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: () => handleProcessing( + () => handleShareAssets( + ref, + context, + selectedAssets.value.toList(), + ), + ), + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.ios_share_rounded), + ), + ElevatedButton( + onPressed: () => handleProcessing( + () => handleFavoriteAssets( + ref, + context, + selectedAssets.value.toList(), + ), + ), + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.favorite), + ), + ElevatedButton( + onPressed: () => handleProcessing( + () => handleArchiveAssets( + ref, + context, + selectedAssets.value.toList(), + ), + true, + ), + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.archive), + ), + ], + ), + ), + ], + ); + } +} 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<MapEvent> mapEventStream; + final Function(String)? onGridAssetChanged; + final Function(String)? onZoomToAsset; + final Function(bool, Set<Asset>)? onAssetsSelected; + final ValueNotifier<Set<Asset>> selectedAssets; + final ScrollController controller; + + const MapAssetGrid({ + required this.mapEventStream, + this.onGridAssetChanged, + this.onZoomToAsset, + this.onAssetsSelected, + required this.selectedAssets, + required this.controller, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final log = Logger("MapAssetGrid"); + final assetsInBounds = useState<List<Asset>>([]); + final cachedRenderList = useRef<RenderList?>(null); + final lastRenderElementIndex = useRef<int?>(null); + final assetInSheet = useValueNotifier<String?>(null); + final gridScrollThrottler = + useThrottler(interval: const Duration(milliseconds: 300)); + + void handleMapEvents(MapEvent event) async { + if (event is MapAssetsInBoundsUpdated) { + assetsInBounds.value = await ref + .read(dbProvider) + .assets + .getAllByRemoteId(event.assetRemoteIds); + return; + } + } + + useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents); + + // Hard-restrict to 4 assets / row in portrait mode + const assetsPerRow = 4; + + void handleVisibleItems(Iterable<ItemPosition> positions) { + final orderedPos = positions.sortedByField((p) => p.index); + // Index of row where the items are mostly visible + const partialOffset = 0.20; + final item = orderedPos + .firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset); + + // Guard no elements, reset state + // Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0) + if (item == null || item.itemLeadingEdge == 0) { + lastRenderElementIndex.value = null; + return; + } + + final renderElement = + cachedRenderList.value?.elements.elementAtOrNull(item.index); + // Guard no render list or render element + if (renderElement == null) { + return; + } + // Reset index + lastRenderElementIndex.value == item.index; + + // <RenderElement:offset:0> + // | 1 | 2 | 3 | 4 | 5 | 6 | + // <RenderElement:offset:6> + // | 7 | 8 | 9 | + // <RenderElement:offset:9> + // | 10 | + + // Skip through the assets from the previous row + final rowOffset = renderElement.offset; + // Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset + final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge; + final edgeOffset = (totalOffset - partialOffset) / + // Round the total count to the next multiple of [assetsPerRow] + ((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor(); + + // trailing should never be above the totalOffset + final columnOffset = + (totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/ + edgeOffset; + final assetOffset = rowOffset + columnOffset; + final selectedAsset = cachedRenderList.value?.allAssets + ?.elementAtOrNull(assetOffset) + ?.remoteId; + + if (selectedAsset != null) { + onGridAssetChanged?.call(selectedAsset); + assetInSheet.value = selectedAsset; + } + } + + return Card( + margin: EdgeInsets.zero, + child: Stack( + children: [ + /// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the + /// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves + Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + // Place it just below the drag handle + heightFactor: 0.80, + child: assetsInBounds.value.isNotEmpty + ? ref.watch(renderListProvider(assetsInBounds.value)).when( + data: (renderList) { + // Cache render list here to use it back during visibleItemsListener + cachedRenderList.value = renderList; + return ValueListenableBuilder( + valueListenable: selectedAssets, + builder: (_, value, __) => ImmichAssetGrid( + shrinkWrap: true, + renderList: renderList, + showDragScroll: false, + assetsPerRow: assetsPerRow, + showMultiSelectIndicator: false, + selectionActive: value.isNotEmpty, + listener: onAssetsSelected, + visibleItemsListener: (pos) => gridScrollThrottler + .run(() => handleVisibleItems(pos)), + ), + ); + }, + error: (error, stackTrace) { + log.warning( + "Cannot get assets in the current map bounds $error", + error, + stackTrace, + ); + return const SizedBox.shrink(); + }, + loading: () => const SizedBox.shrink(), + ) + : _MapNoAssetsInSheet(), + ), + ), + _MapSheetDragRegion( + controller: controller, + assetsInBoundCount: assetsInBounds.value.length, + assetInSheet: assetInSheet, + onZoomToAsset: onZoomToAsset, + ), + ], + ), + ); + } +} + +class _MapNoAssetsInSheet extends StatelessWidget { + @override + Widget build(BuildContext context) { + const image = Image( + height: 150, + width: 150, + image: AssetImage('assets/lighthouse.png'), + ); + + return Center( + child: ListView( + shrinkWrap: true, + children: [ + context.isDarkTheme + ? const InvertionFilter( + child: SaturationFilter( + saturation: -1, + child: BrightnessFilter( + brightness: -5, + child: image, + ), + ), + ) + : image, + const SizedBox(height: 20), + Center( + child: Text( + "map_zoom_to_see_photos".tr(), + style: context.textTheme.displayLarge?.copyWith(fontSize: 18), + ), + ), + ], + ), + ); + } +} + +class _MapSheetDragRegion extends StatelessWidget { + final ScrollController controller; + final int assetsInBoundCount; + final ValueNotifier<String?> assetInSheet; + final Function(String)? onZoomToAsset; + + const _MapSheetDragRegion({ + required this.controller, + required this.assetsInBoundCount, + required this.assetInSheet, + this.onZoomToAsset, + }); + + @override + Widget build(BuildContext context) { + final assetsInBoundsText = assetsInBoundCount > 0 + ? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()]) + : "map_no_assets_in_bounds".tr(); + + return SingleChildScrollView( + controller: controller, + physics: const ClampingScrollPhysics(), + child: Card( + margin: EdgeInsets.zero, + shape: context.isMobile ? null : const BeveledRectangleBorder(), + elevation: 0.0, + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 15), + const CustomDraggingHandle(), + const SizedBox(height: 15), + Text(assetsInBoundsText, style: context.textTheme.bodyLarge), + const Divider(height: 35), + ], + ), + ValueListenableBuilder( + valueListenable: assetInSheet, + builder: (_, value, __) => Visibility( + visible: value != null, + child: Positioned( + right: 15, + top: 15, + child: IconButton( + icon: Icon( + Icons.map_outlined, + color: context.textTheme.displayLarge?.color, + ), + iconSize: 20, + tooltip: 'Zoom to bounds', + onPressed: () => onZoomToAsset?.call(value!), + ), + ), + ), + ), + ], + ), + ), + ); + } +} 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<MapEvent> mapEventStream; + final Function(String)? onGridAssetChanged; + final Function(String)? onZoomToAsset; + final Function()? onZoomToLocation; + final Function(bool, Set<Asset>)? onAssetsSelected; + final ValueNotifier<Set<Asset>> selectedAssets; + + const MapBottomSheet({ + required this.mapEventStream, + this.onGridAssetChanged, + this.onZoomToAsset, + this.onAssetsSelected, + this.onZoomToLocation, + required this.selectedAssets, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + const sheetMinExtent = 0.1; + final sheetController = useDraggableScrollController(); + final bottomSheetOffset = useValueNotifier(sheetMinExtent); + final isBottomSheetOpened = useRef(false); + + void handleMapEvents(MapEvent event) async { + if (event is MapCloseBottomSheet) { + sheetController.animateTo( + 0.1, + duration: const Duration(milliseconds: 200), + curve: Curves.linearToEaseOut, + ); + } + } + + useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents); + + bool onScrollNotification(DraggableScrollableNotification notification) { + isBottomSheetOpened.value = + notification.extent > (notification.maxExtent * 0.9); + bottomSheetOffset.value = notification.extent; + // do not bubble + return true; + } + + return Stack( + children: [ + NotificationListener<DraggableScrollableNotification>( + onNotification: onScrollNotification, + child: DraggableScrollableSheet( + controller: sheetController, + minChildSize: sheetMinExtent, + maxChildSize: 0.5, + initialChildSize: sheetMinExtent, + snap: true, + shouldCloseOnMinExtent: false, + builder: (ctx, scrollController) => MapAssetGrid( + controller: scrollController, + mapEventStream: mapEventStream, + selectedAssets: selectedAssets, + onAssetsSelected: onAssetsSelected, + // Do not bother with the event if the bottom sheet is not user scrolled + onGridAssetChanged: (assetId) => isBottomSheetOpened.value + ? onGridAssetChanged?.call(assetId) + : null, + onZoomToAsset: onZoomToAsset, + ), + ), + ), + ValueListenableBuilder( + valueListenable: bottomSheetOffset, + builder: (ctx, value, child) => Positioned( + right: 0, + bottom: context.height * (value + 0.02), + child: child!, + ), + child: ElevatedButton( + onPressed: onZoomToLocation, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.my_location), + ), + ), + ], + ); + } +} 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<String> style) mapBuilder; + + const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key}); + + @override + ConsumerState createState() => _MapThemeOverideState(); +} + +class _MapThemeOverideState extends ConsumerState<MapThemeOveride> + with WidgetsBindingObserver { + late ThemeMode _theme; + bool _isDarkTheme = false; + + bool get _isSystemDark => + WidgetsBinding.instance.platformDispatcher.platformBrightness == + Brightness.dark; + + bool checkDarkTheme() { + return _theme == ThemeMode.dark || + _theme == ThemeMode.system && _isSystemDark; + } + + @override + void initState() { + super.initState(); + _theme = widget.themeMode ?? + ref.read(mapStateNotifierProvider.select((v) => v.themeMode)); + setState(() { + _isDarkTheme = checkDarkTheme(); + }); + if (_theme == ThemeMode.system) { + WidgetsBinding.instance.addObserver(this); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_theme != ThemeMode.system) { + WidgetsBinding.instance.removeObserver(this); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangePlatformBrightness() { + super.didChangePlatformBrightness(); + + if (_theme == ThemeMode.system) { + setState(() => _isDarkTheme = _isSystemDark); + } + } + + @override + Widget build(BuildContext context) { + _theme = widget.themeMode ?? + ref.watch(mapStateNotifierProvider.select((v) => v.themeMode)); + + useValueChanged<ThemeMode, void>(_theme, (_, __) { + if (_theme == ThemeMode.system) { + WidgetsBinding.instance.addObserver(this); + } else { + WidgetsBinding.instance.removeObserver(this); + } + setState(() { + _isDarkTheme = checkDarkTheme(); + }); + }); + + return Theme( + data: _isDarkTheme ? immichDarkTheme : immichLightTheme, + child: widget.mapBuilder.call( + ref.watch( + mapStateNotifierProvider.select( + (v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched, + ), + ), + ), + ); + } +} 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<double>, LatLng)? onTap; + final LatLng centre; + final String? assetMarkerRemoteId; + final bool showMarkerPin; + final double zoom; + final double height; + final double width; + final ThemeMode? themeMode; + final bool showAttribution; + + const MapThumbnail({ + super.key, + required this.centre, + this.height = 100, + this.width = 100, + this.onTap, + this.zoom = 8, + this.assetMarkerRemoteId, + this.showMarkerPin = false, + this.themeMode, + this.showAttribution = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); + final controller = useRef<MaplibreMapController?>(null); + final position = useValueNotifier<Point<num>?>(null); + + Future<void> onMapCreated(MaplibreMapController mapController) async { + controller.value = mapController; + if (assetMarkerRemoteId != null) { + // The iOS impl returns wrong toScreenLocation without the delay + Future.delayed( + const Duration(milliseconds: 100), + () async => + position.value = await mapController.toScreenLocation(centre), + ); + } + } + + Future<void> onStyleLoaded() async { + if (showMarkerPin && controller.value != null) { + await controller.value?.addMarkerAtLatLng(centre); + } + } + + return MapThemeOveride( + themeMode: themeMode, + mapBuilder: (style) => SizedBox( + height: height, + width: width, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: Stack( + alignment: Alignment.center, + children: [ + style.widgetWhen( + onData: (style) => MaplibreMap( + initialCameraPosition: + CameraPosition(target: offsettedCentre, zoom: zoom), + styleString: style, + onMapCreated: onMapCreated, + onStyleLoadedCallback: onStyleLoaded, + onMapClick: onTap, + doubleClickZoomEnabled: false, + dragEnabled: false, + zoomGesturesEnabled: false, + tiltGesturesEnabled: false, + scrollGesturesEnabled: false, + rotateGesturesEnabled: false, + myLocationEnabled: false, + attributionButtonMargins: + showAttribution == false ? const Point(-100, 0) : null, + ), + ), + ValueListenableBuilder( + valueListenable: position, + builder: (_, value, __) => value != null + ? PositionedAssetMarkerIcon( + size: height / 2, + point: value, + assetRemoteId: assetMarkerRemoteId!, + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ); + } +} 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<num> point; + final String assetRemoteId; + final double size; + final int durationInMilliseconds; + + final Function()? onTap; + + const PositionedAssetMarkerIcon({ + required this.point, + required this.assetRemoteId, + this.size = 100, + this.durationInMilliseconds = 100, + this.onTap, super.key, + }); + + @override + Widget build(BuildContext context) { + final ratio = Platform.isIOS ? 1.0 : MediaQuery.devicePixelRatioOf(context); + return AnimatedPositioned( + left: point.x / ratio - size / 2, + top: point.y / ratio - size, + duration: Duration(milliseconds: durationInMilliseconds), + child: GestureDetector( + onTap: () => onTap?.call(), + child: SizedBox.square( + dimension: size, + child: _AssetMarkerIcon( + id: assetRemoteId, + key: Key(assetRemoteId), + ), + ), + ), + ); + } +} + +class _AssetMarkerIcon extends StatelessWidget { + const _AssetMarkerIcon({ required this.id, - this.isDarkTheme = false, + super.key, }); final String id; - final bool isDarkTheme; @override Widget build(BuildContext context) { @@ -26,8 +66,8 @@ class AssetMarkerIcon extends StatelessWidget { left: constraints.maxWidth * 0.5, child: CustomPaint( painter: _PinPainter( - primaryColor: isDarkTheme ? Colors.white : Colors.black, - secondaryColor: isDarkTheme ? Colors.black : Colors.white, + primaryColor: context.colorScheme.onSurface, + secondaryColor: context.colorScheme.surface, primaryRadius: constraints.maxHeight * 0.06, secondaryRadius: constraints.maxHeight * 0.038, ), @@ -42,7 +82,7 @@ class AssetMarkerIcon extends StatelessWidget { left: constraints.maxWidth * 0.17, child: CircleAvatar( radius: constraints.maxHeight * 0.40, - backgroundColor: isDarkTheme ? Colors.white : Colors.black, + backgroundColor: context.colorScheme.onSurface, child: CircleAvatar( radius: constraints.maxHeight * 0.37, backgroundImage: CachedNetworkImageProvider( @@ -72,8 +112,8 @@ class _PinPainter extends CustomPainter { final double secondaryRadius; _PinPainter({ - this.primaryColor = Colors.black, - this.secondaryColor = Colors.white, + required this.primaryColor, + required this.secondaryColor, required this.primaryRadius, required this.secondaryRadius, }); diff --git a/mobile/lib/modules/search/services/person.service.g.dart b/mobile/lib/modules/search/services/person.service.g.dart index b80b439d1d3b9132c2c98f30fe2d4e0ef150377d..01a5ed8f309434afa387cbb946ebe1514667207f 100644 GIT binary patch delta 53 zcmeBX?`Gd1&!}N)l4_QcW|Cx)nwn^CVv=T-l4@XJnPy>ZXqs%9mTG2XY>;MVZfUXE IozaB}0FvJhZvX%Q delta 53 zcmeBX?`Gd1&!~}{l4_7>nQUNWnPO;`l9G~Yl$es7Vrr3?YLRSVW}K8}k(_LpmYTBJ IozaB}0Ir%40RR91 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<T> { advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), - mapThemeMode<bool>(StoreKey.mapThemeMode, null, false), + mapThemeMode<int>(StoreKey.mapThemeMode, null, 0), mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false), mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false), mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0), 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<void> { class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { MapLocationPickerRoute({ Key? key, - LatLng? initialLatLng, + LatLng initialLatLng = const LatLng(0, 0), }) : super( MapLocationPickerRoute.name, path: '/map-location-picker-page', @@ -1609,12 +1609,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> { class MapLocationPickerRouteArgs { const MapLocationPickerRouteArgs({ this.key, - this.initialLatLng, + this.initialLatLng = const LatLng(0, 0), }); final Key? key; - final LatLng? initialLatLng; + final LatLng initialLatLng; @override String toString() { 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<dynamic> _cache = List.filled(StoreKey.values.map((e) => e.id).max + 1, null); @@ -72,8 +74,12 @@ class Store { static void _onChangeListener(List<StoreValue>? data) { if (data != null) { for (StoreValue value in data) { - _cache[value.id] = - value._extract(StoreKey.values.firstWhere((e) => e.id == value.id)); + final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id); + if (key != null) { + _cache[value.id] = value._extract(key); + } else { + _log.warning("No key available for value id - ${value.id}"); + } } } } @@ -177,13 +183,13 @@ enum StoreKey<T> { logLevel<int>(115, type: int), preferRemoteImage<bool>(116, type: bool), // map related settings - mapThemeMode<bool>(117, type: bool), mapShowFavoriteOnly<bool>(118, type: bool), mapRelativeDate<int>(119, type: int), selfSignedCert<bool>(120, type: bool), mapIncludeArchived<bool>(121, type: bool), ignoreIcloudAssets<bool>(122, type: bool), selectedAlbumSortReverse<bool>(123, type: bool), + mapThemeMode<int>(124, type: int), ; const StoreKey( 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<WebsocketState> { final _log = Logger('WebsocketNotifier'); final Ref _ref; - final Debounce _debounce = Debounce(const Duration(milliseconds: 500)); + final Debouncer _debounce = + Debouncer(interval: const Duration(milliseconds: 500)); /// Connects websocket to server unless already connected void connect() { @@ -194,7 +195,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { PendingChange(now.millisecondsSinceEpoch.toString(), action, value), ], ); - _debounce(handlePendingChanges); + _debounce.run(handlePendingChanges); } Future<void> _handlePendingDeletes() async { 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<LatLng?> showLocationPicker({ required BuildContext context, @@ -25,16 +24,6 @@ Future<LatLng?> showLocationPicker({ enum _LocationPickerMode { map, manual } -bool _validateLat(String value) { - final l = double.tryParse(value); - return l != null && l > -90 && l < 90; -} - -bool _validateLong(String value) { - final l = double.tryParse(value); - return l != null && l > -180 && l < 180; -} - class _LocationPicker extends HookWidget { final LatLng? initialLatLng; @@ -48,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<void> onMapTap() async { + final newLatLng = await context.pushRoute<LatLng?>( + 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<Widget> buildMapPickerMode() { - return [ - TextButton.icon( - icon: Text( - "${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}", - ), - label: const Icon(Icons.edit_outlined, size: 16), - onPressed: () { - latitudeController.text = latitude.value.toStringAsFixed(4); - longitudeController.text = longitude.value.toStringAsFixed(4); - pickerMode.value = _LocationPickerMode.manual; - }, - ), - const SizedBox( - height: 12, - ), - MapThumbnail( - coords: latlng, - height: 200, - width: 200, - zoom: 6, - showAttribution: false, - onTap: (p0, p1) async { - final newLatLng = await context.pushRoute<LatLng?>( - 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<Widget> buildManualPickerMode() { - return [ - TextButton.icon( - icon: const Text("location_picker_choose_on_map").tr(), - label: const Icon(Icons.map_outlined, size: 16), - onPressed: () { - validateInputs(); - if (isValidLatitude.value && isValidLongitude.value) { - pickerMode.value = _LocationPickerMode.map; - } - }, - ), - const SizedBox( - height: 12, - ), - TextField( - controller: latitudeController, - focusNode: latitiudeFocusNode, - textInputAction: TextInputAction.done, - autofocus: false, - decoration: InputDecoration( - labelText: 'location_picker_latitude'.tr(), - labelStyle: TextStyle( - fontWeight: FontWeight.bold, - color: context.primaryColor, - ), - floatingLabelBehavior: FloatingLabelBehavior.auto, - border: const OutlineInputBorder(), - hintText: 'location_picker_latitude_hint'.tr(), - hintStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, - ), - errorText: isValidLatitude.value - ? null - : "location_picker_latitude_error".tr(), - ), - onEditingComplete: () { - isValidLatitude.value = _validateLat(latitudeController.text); - if (isValidLatitude.value) { - latitude.value = latitudeController.text.toDouble(); - longitudeFocusNode.requestFocus(); - } - }, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [LengthLimitingTextInputFormatter(8)], - onTapOutside: (_) => latitiudeFocusNode.unfocus(), - ), - const SizedBox( - height: 24, - ), - TextField( - controller: longitudeController, - focusNode: longitudeFocusNode, - textInputAction: TextInputAction.done, - autofocus: false, - decoration: InputDecoration( - labelText: 'location_picker_longitude'.tr(), - labelStyle: TextStyle( - fontWeight: FontWeight.bold, - color: context.primaryColor, - ), - floatingLabelBehavior: FloatingLabelBehavior.auto, - border: const OutlineInputBorder(), - hintText: 'location_picker_longitude_hint'.tr(), - hintStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, - ), - errorText: isValidLongitude.value - ? null - : "location_picker_longitude_error".tr(), - ), - onEditingComplete: () { - isValidLongitude.value = _validateLong(longitudeController.text); - if (isValidLongitude.value) { - longitude.value = longitudeController.text.toDouble(); - longitudeFocusNode.unfocus(); - } - }, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [LengthLimitingTextInputFormatter(8)], - onTapOutside: (_) => longitudeFocusNode.unfocus(), - ), - ]; } return AlertDialog( contentPadding: const EdgeInsets.all(30), alignment: Alignment.center, content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "edit_location_dialog_title", - textAlign: TextAlign.center, - ).tr(), - const SizedBox( - height: 12, - ), - if (pickerMode.value == _LocationPickerMode.manual) - ...buildManualPickerMode(), - if (pickerMode.value == _LocationPickerMode.map) - ...buildMapPickerMode(), - ], - ), + child: pickerMode.value == _LocationPickerMode.map + ? _MapPicker( + key: ValueKey(latlng), + latlng: latlng, + onModeSwitch: () => + pickerMode.value = _LocationPickerMode.manual, + onMapTap: onMapTap, + ) + : _ManualPicker( + latlng: latlng, + onModeSwitch: () => pickerMode.value = _LocationPickerMode.map, + onLatUpdated: (value) => latitude.value = value, + onLonUpdated: (value) => longitude.value = value, + ), ), actions: [ 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<void> Function()? _lastAction; - void call(VoidCallback? action) { - this.action = action; + void run(FutureOr<void> Function() action) { + _lastAction = action; _timer?.cancel(); - _timer = Timer(Duration(milliseconds: _interval), _callAndRest); + _timer = Timer(interval, _callAndRest); } void _callAndRest() { - action?.call(); + _lastAction?.call(); _timer = null; } void dispose() { _timer?.cancel(); _timer = null; + _lastAction = null; } } + +/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a +/// default interval of 300ms is used to debounce the function calls +Debouncer useDebouncer({ + Duration interval = const Duration(milliseconds: 300), + List<Object?>? keys, +}) => + use(_DebouncerHook(interval: interval, keys: keys)); + +class _DebouncerHook extends Hook<Debouncer> { + const _DebouncerHook({ + required this.interval, + List<Object?>? keys, + }) : super(keys: keys); + + final Duration interval; + + @override + HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState(); +} + +class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> { + late final debouncer = Debouncer(interval: hook.interval); + + @override + Debouncer build(_) => debouncer; + + @override + void dispose() => debouncer.dispose(); + + @override + String get debugLabel => 'useDebouncer'; +} 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<Object?>? keys, +}) { + return use( + _DraggableScrollControllerHook( + keys: keys, + ), + ); +} + +class _DraggableScrollControllerHook + extends Hook<DraggableScrollableController> { + const _DraggableScrollControllerHook({ + List<Object?>? keys, + }) : super(keys: keys); + + @override + HookState<DraggableScrollableController, Hook<DraggableScrollableController>> + createState() => _DraggableScrollControllerHookState(); +} + +class _DraggableScrollControllerHookState extends HookState< + DraggableScrollableController, _DraggableScrollControllerHook> { + late final controller = DraggableScrollableController(); + + @override + DraggableScrollableController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useDraggableScrollController'; +} 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<void> Function() action) { + if (_lastActionTime == null || + (DateTime.now().difference(_lastActionTime!) > interval)) { + action(); + _lastActionTime = DateTime.now(); + } + } + + void dispose() { + _lastActionTime = null; + } +} + +/// Creates a [Throttler] that will be disposed automatically. If no [interval] is provided, a +/// default interval of 300ms is used to throttle the function calls +Throttler useThrottler({ + Duration interval = const Duration(milliseconds: 300), + List<Object?>? keys, +}) => + use(_ThrottleHook(interval: interval, keys: keys)); + +class _ThrottleHook extends Hook<Throttler> { + const _ThrottleHook({ + required this.interval, + List<Object?>? keys, + }) : super(keys: keys); + + final Duration interval; + + @override + HookState<Throttler, Hook<Throttler>> createState() => _ThrottlerHookState(); +} + +class _ThrottlerHookState extends HookState<Throttler, _ThrottleHook> { + late final throttler = Throttler(interval: hook.interval); + + @override + Throttler build(_) => throttler; + + @override + void dispose() => throttler.dispose(); + + @override + String get debugLabel => 'useThrottler'; +} 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<MapState> + 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<Override> overrides; + late MapState mapState; + + setUpAll(() async { + TestUtils.init(); + }); + + setUp(() { + mapState = MapState(themeMode: ThemeMode.dark); + mapStateNotifier = MockMapStateNotifier(mapState); + overrides = [mapStateNotifierProvider.overrideWith(() => mapStateNotifier)]; + }); + + testWidgets("Return dark theme style when theme mode is dark", + (tester) async { + AsyncValue<String>? mapStyle; + await tester.pumpConsumerWidget( + MapThemeOveride( + mapBuilder: (AsyncValue<String> style) { + mapStyle = style; + return const Text("Mock"); + }, + ), + overrides: overrides, + ); + + mapStateNotifier.state = + mapState.copyWith(darkStyleFetched: const AsyncData("dark")); + await tester.pumpAndSettle(); + expect(mapStyle?.valueOrNull, "dark"); + }); + + testWidgets("Return error when style is not fetched", (tester) async { + AsyncValue<String>? mapStyle; + await tester.pumpConsumerWidget( + MapThemeOveride( + mapBuilder: (AsyncValue<String> style) { + mapStyle = style; + return const Text("Mock"); + }, + ), + overrides: overrides, + ); + + mapStateNotifier.state = mapState.copyWith( + darkStyleFetched: const AsyncError("Error", StackTrace.empty), + ); + await tester.pumpAndSettle(); + expect(mapStyle?.hasError, isTrue); + }); + + testWidgets("Return light theme style when theme mode is light", + (tester) async { + AsyncValue<String>? mapStyle; + await tester.pumpConsumerWidget( + MapThemeOveride( + mapBuilder: (AsyncValue<String> style) { + mapStyle = style; + return const Text("Mock"); + }, + ), + overrides: overrides, + ); + + mapStateNotifier.state = mapState.copyWith( + themeMode: ThemeMode.light, + lightStyleFetched: const AsyncData("light"), + ); + await tester.pumpAndSettle(); + expect(mapStyle?.valueOrNull, "light"); + }); + + group("System mode", () { + testWidgets("Return dark theme style when system is dark", (tester) async { + AsyncValue<String>? mapStyle; + await tester.pumpConsumerWidget( + MapThemeOveride( + mapBuilder: (AsyncValue<String> style) { + mapStyle = style; + return const Text("Mock"); + }, + ), + overrides: overrides, + ); + + tester.binding.platformDispatcher.platformBrightnessTestValue = + Brightness.dark; + mapStateNotifier.state = mapState.copyWith( + themeMode: ThemeMode.system, + darkStyleFetched: const AsyncData("dark"), + ); + await tester.pumpAndSettle(); + + expect(mapStyle?.valueOrNull, "dark"); + }); + + testWidgets("Return light theme style when system is light", + (tester) async { + AsyncValue<String>? mapStyle; + await tester.pumpConsumerWidget( + MapThemeOveride( + mapBuilder: (AsyncValue<String> style) { + mapStyle = style; + return const Text("Mock"); + }, + ), + overrides: overrides, + ); + + tester.binding.platformDispatcher.platformBrightnessTestValue = + Brightness.light; + mapStateNotifier.state = mapState.copyWith( + themeMode: ThemeMode.system, + lightStyleFetched: const AsyncData("light"), + ); + await tester.pumpAndSettle(); + + expect(mapStyle?.valueOrNull, "light"); + }); + + testWidgets("Switches style when system brightness changes", + (tester) async { + AsyncValue<String>? mapStyle; + await tester.pumpConsumerWidget( + MapThemeOveride( + mapBuilder: (AsyncValue<String> style) { + mapStyle = style; + return const Text("Mock"); + }, + ), + overrides: overrides, + ); + + tester.binding.platformDispatcher.platformBrightnessTestValue = + Brightness.light; + mapStateNotifier.state = mapState.copyWith( + themeMode: ThemeMode.system, + lightStyleFetched: const AsyncData("light"), + darkStyleFetched: const AsyncData("dark"), + ); + await tester.pumpAndSettle(); + expect(mapStyle?.valueOrNull, "light"); + + tester.binding.platformDispatcher.platformBrightnessTestValue = + Brightness.dark; + await tester.pumpAndSettle(); + expect(mapStyle?.valueOrNull, "dark"); + }); + }); +} 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<int>.generate(10)) { + throttler.run(() => counter.increment()); + await Future.delayed(const Duration(milliseconds: 50)); + } + await Future.delayed(const Duration(seconds: 1)); + expect(counter.count, 5); + }); +}