optimizations

This commit is contained in:
mertalev
2025-07-30 19:30:42 -04:00
parent 196f2a72f4
commit 6cea779b2d
23 changed files with 7004 additions and 329 deletions
@@ -1,5 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/map/marker_build.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -24,35 +23,39 @@ class MapState {
class MapStateNotifier extends Notifier<MapState> {
MapStateNotifier();
void setBounds(LatLngBounds bounds) {
bool setBounds(LatLngBounds bounds) {
if (state.bounds == bounds) {
return false;
}
state = state.copyWith(bounds: bounds);
return true;
}
@override
MapState build() => MapState(
// TODO: set default bounds
bounds: LatLngBounds(
northeast: const LatLng(0, 0),
southwest: const LatLng(0, 0),
),
);
// TODO: set default bounds
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
}
// This provider watches the markers from the map service and serves the markers.
// It should be used only after the map service provider is overridden
final mapMarkerProvider =
StreamProvider.family<Map<String, dynamic>, LatLngBounds?>(
(ref, bounds) async* {
final mapService = ref.watch(mapServiceProvider);
yield* mapService.watchMarkers(bounds).map((markers) {
return MarkerBuilder(
markers: markers,
).generate();
});
},
dependencies: [mapServiceProvider],
);
final mapMarkerProvider = FutureProvider.family<Map<String, dynamic>, LatLngBounds?>((ref, bounds) async {
final mapService = ref.watch(mapServiceProvider);
final markers = await mapService.getMarkers(bounds);
final features = List.filled(markers.length, const <String, dynamic>{});
for (int i = 0; i < markers.length; i++) {
final marker = markers[i];
features[i] = {
'type': 'Feature',
'id': marker.assetId,
'geometry': {
'type': 'Point',
'coordinates': [marker.location.longitude, marker.location.latitude],
},
};
}
return {'type': 'FeatureCollection', 'features': features};
}, dependencies: [mapServiceProvider]);
final mapStateProvider = NotifierProvider<MapStateNotifier, MapState>(
MapStateNotifier.new,
);
final mapStateProvider = NotifierProvider<MapStateNotifier, MapState>(MapStateNotifier.new);
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -11,10 +10,29 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class CustomSourceProperties implements SourceProperties {
final Map<String, dynamic> data;
const CustomSourceProperties({required this.data});
@override
Map<String, dynamic> toJson() {
return {
"type": "geojson",
"data": data,
// "cluster": true,
// "clusterRadius": 1,
// "clusterMinPoints": 5,
// "tolerance": 0.1,
};
}
}
class DriftMap extends ConsumerStatefulWidget {
const DriftMap({super.key});
@@ -24,7 +42,8 @@ class DriftMap extends ConsumerStatefulWidget {
class _DriftMapState extends ConsumerState<DriftMap> {
MapLibreMapController? mapController;
bool loadAllMarkers = false;
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 250), maxWaitTime: const Duration(seconds: 2));
@override
void initState() {
@@ -33,76 +52,69 @@ class _DriftMapState extends ConsumerState<DriftMap> {
@override
void dispose() {
mapController?.removeListener(onMapMoved);
mapController?.dispose();
_debouncer.dispose();
super.dispose();
}
Future<void> onMapCreated(MapLibreMapController controller) async {
void onMapCreated(MapLibreMapController controller) {
mapController = controller;
await setBounds();
}
Future<void> onMapMoved() async {
await setBounds();
Future<void> onMapReady() async {
final controller = mapController;
if (controller == null) {
return;
}
await controller.addSource(
MapUtils.defaultSourceId,
const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
);
await controller.addHeatmapLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatmapLayerProperties,
);
controller.addListener(onMapMoved);
}
void onMapMoved() {
if (mapController!.isCameraMoving || !mounted) {
return;
}
_debouncer.run(setBounds);
}
Future<void> setBounds() async {
if (mapController == null) return;
final bounds = await mapController!.getVisibleRegion();
ref.read(mapStateProvider.notifier).setBounds(bounds);
final controller = mapController;
if (controller == null || !mounted) {
return;
}
final bounds = await controller.getVisibleRegion();
_reloadMutex.run(() async {
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
final markers = await ref.read(mapMarkerProvider(bounds).future);
await reloadMarkers(markers);
}
});
}
Future<void> reloadMarkers(
Map<String, dynamic> markers, {
bool isLoadAllMarkers = false,
}) async {
if (mapController == null || loadAllMarkers) return;
// Wait for previous reload to complete
if (!MapUtils.markerCompleter.isCompleted) {
return MapUtils.markerCompleter.future;
}
MapUtils.markerCompleter = Completer();
// !! 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 mapController!.getLayerIds();
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
await mapController!.removeLayer(MapUtils.defaultHeatMapLayerId);
Future<void> reloadMarkers(Map<String, dynamic> markers) async {
final controller = mapController;
if (controller == null || !mounted) {
return;
}
final existingSources = await mapController!.getSourceIds();
if (existingSources.contains(MapUtils.defaultSourceId)) {
await mapController!.removeSource(MapUtils.defaultSourceId);
}
await mapController!.addSource(
MapUtils.defaultSourceId,
GeojsonSourceProperties(data: markers),
);
if (Platform.isAndroid) {
await mapController!.addCircleLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultCircleLayerLayerProperties,
);
} else if (Platform.isIOS) {
await mapController!.addHeatmapLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatmapLayerProperties,
);
}
if (isLoadAllMarkers) loadAllMarkers = true;
MapUtils.markerCompleter.complete();
await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers);
}
Future<void> onZoomToLocation() async {
final (location, error) =
await MapUtils.checkPermAndGetLocation(context: context);
final (location, error) = await MapUtils.checkPermAndGetLocation(context: context);
if (error != null) {
if (error == LocationPermission.unableToDetermine && context.mounted) {
ImmichToast.show(
@@ -115,12 +127,10 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return;
}
if (mapController != null && location != null) {
mapController!.animateCamera(
CameraUpdate.newLatLngZoom(
LatLng(location.latitude, location.longitude),
MapUtils.mapZoomToAssetLevel,
),
final controller = mapController;
if (controller != null && location != null) {
controller.animateCamera(
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
);
}
@@ -128,28 +138,9 @@ class _DriftMapState extends ConsumerState<DriftMap> {
@override
Widget build(BuildContext context) {
final bounds = ref.watch(mapStateProvider.select((s) => s.bounds));
AsyncValue<Map<String, dynamic>> markers =
ref.watch(mapMarkerProvider(bounds));
AsyncValue<Map<String, dynamic>> allMarkers =
ref.watch(mapMarkerProvider(null));
ref.listen(mapStateProvider, (_, __) async {
if (!loadAllMarkers) {
markers = ref.watch(mapMarkerProvider(bounds));
}
});
markers.whenData((markers) => reloadMarkers(markers));
allMarkers
.whenData((markers) => reloadMarkers(markers, isLoadAllMarkers: true));
return Stack(
children: [
_Map(
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
),
_Map(onMapCreated: onMapCreated, onMapReady: onMapReady),
_MyLocationButton(onZoomToLocation: onZoomToLocation),
const MapBottomSheet(),
],
@@ -158,26 +149,21 @@ class _DriftMapState extends ConsumerState<DriftMap> {
}
class _Map extends StatelessWidget {
const _Map({
required this.onMapCreated,
required this.onMapMoved,
});
const _Map({required this.onMapCreated, required this.onMapReady});
final MapCreatedCallback onMapCreated;
final OnCameraIdleCallback onMapMoved;
final VoidCallback onMapReady;
@override
Widget build(BuildContext context) {
return MapThemeOverride(
mapBuilder: (style) => style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: const CameraPosition(
target: LatLng(0, 0),
zoom: 0,
),
initialCameraPosition: const CameraPosition(target: LatLng(0, 0), zoom: 0),
styleString: style,
onMapCreated: onMapCreated,
onCameraIdle: onMapMoved,
onStyleLoadedCallback: onMapReady,
),
),
);
@@ -196,9 +182,7 @@ class _MyLocationButton extends StatelessWidget {
bottom: context.padding.bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
);
@@ -73,10 +73,7 @@ class MapUtils {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled && !silent) {
showDialog(
context: context,
builder: (context) => _LocationServiceDisabledDialog(context),
);
showDialog(context: context, builder: (context) => _LocationServiceDisabledDialog(context));
return (null, LocationPermission.deniedForever);
}
@@ -93,12 +90,9 @@ class MapUtils {
}
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission &&
!silent) {
if (permission == LocationPermission.deniedForever && !shouldRequestPermission && !silent) {
await Geolocator.openAppSettings();
}
return (null, LocationPermission.deniedForever);
@@ -121,24 +115,24 @@ class MapUtils {
class _LocationServiceDisabledDialog extends ConfirmDialog {
_LocationServiceDisabledDialog(BuildContext context)
: super(
title: 'map_location_service_disabled_title'.t(context: context),
content: 'map_location_service_disabled_content'.t(context: context),
cancel: 'cancel'.t(context: context),
ok: 'yes'.t(context: context),
onOk: () async {
await Geolocator.openLocationSettings();
},
);
: super(
title: 'map_location_service_disabled_title'.t(context: context),
content: 'map_location_service_disabled_content'.t(context: context),
cancel: 'cancel'.t(context: context),
ok: 'yes'.t(context: context),
onOk: () async {
await Geolocator.openLocationSettings();
},
);
}
class _LocationPermissionDisabledDialog extends ConfirmDialog {
_LocationPermissionDisabledDialog(BuildContext context)
: super(
title: 'map_no_location_permission_title'.t(context: context),
content: 'map_no_location_permission_content'.t(context: context),
cancel: 'cancel'.t(context: context),
ok: 'yes'.t(context: context),
onOk: () {},
);
: super(
title: 'map_no_location_permission_title'.t(context: context),
content: 'map_no_location_permission_content'.t(context: context),
cancel: 'cancel'.t(context: context),
ok: 'yes'.t(context: context),
onOk: () {},
);
}
@@ -1,21 +0,0 @@
import 'package:immich_mobile/domain/models/map.model.dart';
class MarkerBuilder {
final List<Marker> markers;
const MarkerBuilder({required this.markers});
static Map<String, dynamic> addFeature(Marker marker) => {
'type': 'Feature',
'id': marker.assetId,
'geometry': {
'type': 'Point',
'coordinates': [marker.location.longitude, marker.location.latitude],
},
};
Map<String, dynamic> generate() => {
'type': 'FeatureCollection',
'features': markers.map(addFeature).toList(),
};
}