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 1c8ba87885..9bfc53d05b 100644 Binary files a/mobile/assets/location-pin.png and b/mobile/assets/location-pin.png differ 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 53daa74a12..96628dab58 100644 Binary files a/mobile/lib/modules/asset_viewer/providers/current_asset.provider.g.dart and b/mobile/lib/modules/asset_viewer/providers/current_asset.provider.g.dart differ 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 0000000000..7df6adea99 Binary files /dev/null and b/mobile/lib/modules/map/providers/map_marker.provider.g.dart differ diff --git a/mobile/lib/modules/map/providers/map_service.provider.dart b/mobile/lib/modules/map/providers/map_service.provider.dart new file mode 100644 index 0000000000..666ca7acda --- /dev/null +++ b/mobile/lib/modules/map/providers/map_service.provider.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/modules/map/services/map.service.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'map_service.provider.g.dart'; + +@riverpod +MapSerivce mapService(MapServiceRef ref) => + MapSerivce(ref.watch(apiServiceProvider)); diff --git a/mobile/lib/modules/map/providers/map_service.provider.g.dart b/mobile/lib/modules/map/providers/map_service.provider.g.dart new file mode 100644 index 0000000000..7b4e68eaee Binary files /dev/null and b/mobile/lib/modules/map/providers/map_service.provider.g.dart differ 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 0000000000..ca75292e78 Binary files /dev/null and b/mobile/lib/modules/map/providers/map_state.provider.g.dart differ 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 b80b439d1d..01a5ed8f30 100644 Binary files a/mobile/lib/modules/search/services/person.service.g.dart and b/mobile/lib/modules/search/services/person.service.g.dart differ 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); + }); +}