1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-19 00:32:49 +02:00
immich/mobile/lib/modules/map/views/map_page.dart
shenlong e0864768c2
refactor(mobile): map heatmap color and location picker (#6553)
* refactor(mobile): make location picker scaffold primary

* chore(mobile): update map heatmap colors

* style(mobile): map bottomsheet - only use borders on top

* fix(mobile): location picker show buttons above navigation bar

* fix: crash on iOS due to heatmap invalid color format

* disable rotate

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-08 03:07:43 +00:00

411 lines
14 KiB
Dart

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_hooks/flutter_hooks.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/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/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/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:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class MapPage extends HookConsumerWidget {
const MapPage({super.key});
@override
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 layerDebouncer = useDebouncer(interval: const Duration(seconds: 1));
final isLoading = useProcessingOverlay();
final scrollController = useScrollController();
final markerDebouncer =
useDebouncer(interval: const Duration(milliseconds: 800));
final selectedAssets = useValueNotifier<Set<Asset>>({});
const mapZoomToAssetLevel = 12.0;
// 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;
}
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) {
layerDebouncer.run(
() => 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;
},
[],
);
// 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);
});
}
});
// 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 {
final (location, error) = await MapUtils.checkPermAndGetLocation(context);
if (error != null) {
if (error == LocationPermission.unableToDetermine && context.mounted) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: "map_cannot_get_user_location".tr(),
);
}
return;
}
if (mapController.value != null && location != null) {
mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(
LatLng(location.latitude, location.longitude),
mapZoomToAssetLevel,
),
duration: const Duration(milliseconds: 800),
);
}
}
void onAssetsSelected(bool selected, Set<Asset> selection) {
selectedAssets.value = selected ? selection : {};
}
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,
),
),
),
],
),
);
}
}
class _AssetMarkerMeta {
final Point<num> point;
final MapMarker marker;
final bool shouldAnimate;
const _AssetMarkerMeta({
required this.point,
required this.marker,
required this.shouldAnimate,
});
@override
String toString() =>
'_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
}
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,
rotateGesturesEnabled: false,
),
),
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(),
),
],
),
),
);
}
}