mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(mobile): map view (#3661)
* feat(mobile): map page - add map view * map: add map-markers * feat(map): add relative date filter * fix: do not let users scroll past map bounds * fix: fetch relative date from store to state on init * feat(mobile):re-fetch markers only on filter change * feat(mobile) - asset bottom sheet in map page * feat(mobile): display markers based on bottom sheet scroll * fix: exif-bottom-sheet - rebase conflict * feat(mobile): map-view - strongly typed map page events * feat(map): zoom to asset * chore: dart analyzer fixes * map-page move attribution to top-right * feat(mobile): map view - asset selection handling * feat(mobile): map-view display map in places row * fix: make asset marker icon responsive * optimise map page rebuilds * refactor(mobile): map page * feat(mobile): map-view: Go to location * map-view(mobile): minor refactor * fix(mobile): Handle invalid coords gracefully * small styling --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
305889f32b
commit
cb391342d7
37 changed files with 2268 additions and 139 deletions
499
mobile/lib/modules/map/views/map_page.dart
Normal file
499
mobile/lib/modules/map/views/map_page.dart
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/modules/map/models/map_page_event.model.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/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/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/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';
|
||||
|
||||
class MapPage extends StatefulHookConsumerWidget {
|
||||
const MapPage({super.key});
|
||||
|
||||
@override
|
||||
MapPageState createState() => MapPageState();
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
}) {
|
||||
final bounds = mapController.bounds;
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void openAssetInViewer(Asset asset) {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: 0,
|
||||
loadAsset: (index) => asset,
|
||||
totalAssets: 1,
|
||||
heroOffset: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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;
|
||||
if (shouldRefetch) {
|
||||
refetchMarkers.value = shouldRefetch;
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
}
|
||||
});
|
||||
|
||||
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: 6,
|
||||
);
|
||||
if (newCenter != null) {
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(newCenter, 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
12,
|
||||
);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
"Cannot get user's current location due to ${error.toString()}",
|
||||
);
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
msg: "map_cannot_get_user_location".tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
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 onShareAsset() {
|
||||
handleShareAssets(ref, context, selectedAssets.value.toList());
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
||||
void onFavoriteAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void onArchiveAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
|
||||
selectionEnabledHook.value = isMultiSelect;
|
||||
selectedAssets.value = selection;
|
||||
}
|
||||
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final darkTileLayer = InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
isDarkTheme: isDarkTheme,
|
||||
id: closestAssetMarker.value!.asset.remoteId!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final heatMapLayer = mapMarkerData.value.isNotEmpty
|
||||
? HeatMapLayer(
|
||||
heatMapDataSource: InMemoryHeatMapDataSource(
|
||||
data: mapMarkerData.value
|
||||
.map(
|
||||
(e) => WeightedLatLng(
|
||||
LatLng(e.point.latitude, e.point.longitude),
|
||||
1,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
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: Colors.black.withOpacity(0.5),
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
),
|
||||
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: [
|
||||
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: 18, // max level supported by OSM,
|
||||
onMapReady: () {
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
isDarkTheme ? darkTileLayer : tileLayer,
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
selectionEnabled: selectionEnabledHook.value,
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.35,
|
||||
left: MediaQuery.of(context).size.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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue