mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 17:25:35 +03:00
refactor(mobile): maplibre (#6087)
* chore: maplibre gl pubspec * refactor(wip): maplibre for maps * refactor(wip): dual pane + location button * chore: remove flutter_map and deps * refactor(wip): map zoom to location * refactor: location picker * open gallery_viewer on marker tap * remove detectScaleGesture param * test: debounce and throttle * chore: rename get location method * feat(mobile): Adds gps locator to map prompt for easy geolocation (#6282) * Refactored get gps coords * Use var for linter's sake, should handle errors better * Cleanup * Fix linter issues * chore(dep): update maplibre to official lib --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Joshua Herrera <joshua.herrera227@gmail.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user