chore(mobile): replace maplibre_gl with maplibre

maplibre is a ground-up rewrite of maplibre_gl with a more modern and
ergonomic API. It should fix a few bugs we've seen with maps, and
perform better.
This commit is contained in:
Thomas Way
2026-02-21 01:17:06 +00:00
parent 82c6302549
commit c087b7c063
46 changed files with 666 additions and 651 deletions
@@ -17,7 +17,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
@RoutePage()
class DriftLibraryPage extends ConsumerWidget {
@@ -230,7 +230,7 @@ class _PlacesCollectionCard extends StatelessWidget {
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(21.44950, -157.91959),
centre: const Geographic(lat: 21.44950, lon: -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
@RoutePage()
class DriftMapPage extends StatelessWidget {
final LatLng? initialLocation;
final Geographic? initialLocation;
const DriftMapPage({super.key, this.initialLocation});
@@ -10,13 +10,13 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
@RoutePage()
class DriftPlacePage extends StatelessWidget {
const DriftPlacePage({super.key, this.currentLocation});
final LatLng? currentLocation;
final Geographic? currentLocation;
@override
Widget build(BuildContext context) {
@@ -82,7 +82,7 @@ class _Map extends StatelessWidget {
const _Map({required this.search, this.currentLocation});
final ValueNotifier<String?> search;
final LatLng? currentLocation;
final Geographic? currentLocation;
@override
Widget build(BuildContext context) {
@@ -96,7 +96,7 @@ class _Map extends StatelessWidget {
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
centre: currentLocation ?? const Geographic(lat: 21.44950, lon: -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
@@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widge
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
class LocationDetails extends ConsumerStatefulWidget {
const LocationDetails({super.key});
@@ -20,7 +20,7 @@ class LocationDetails extends ConsumerStatefulWidget {
}
class _LocationDetailsState extends ConsumerState<LocationDetails> {
MapLibreMapController? _mapController;
MapController? _mapController;
String? _getLocationName(ExifInfo? exifInfo) {
if (exifInfo == null) {
@@ -36,14 +36,16 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
return null;
}
void _onMapCreated(MapLibreMapController controller) {
void _onMapCreated(MapController controller) {
_mapController = controller;
}
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
_mapController?.moveCamera(
center: Geographic(lat: currentExif.latitude!, lon: currentExif.longitude!),
);
}
}
@@ -7,11 +7,11 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
class MapState {
final ThemeMode themeMode;
final LatLngBounds bounds;
final LngLatBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
@@ -35,7 +35,7 @@ class MapState {
int get hashCode => bounds.hashCode;
MapState copyWith({
LatLngBounds? bounds,
LngLatBounds? bounds,
ThemeMode? themeMode,
bool? onlyFavorites,
bool? includeArchived,
@@ -64,7 +64,7 @@ class MapState {
class MapStateNotifier extends Notifier<MapState> {
MapStateNotifier();
bool setBounds(LatLngBounds bounds) {
bool setBounds(LngLatBounds bounds) {
if (state.bounds == bounds) {
return false;
}
@@ -113,14 +113,14 @@ class MapStateNotifier extends Notifier<MapState> {
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
bounds: const LngLatBounds(longitudeWest: 0, longitudeEast: 0, latitudeSouth: 0, latitudeNorth: 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 = FutureProvider.family<Map<String, dynamic>, LatLngBounds?>((ref, bounds) async {
final mapMarkerProvider = FutureProvider.family<Map<String, dynamic>, LngLatBounds?>((ref, bounds) async {
final mapService = ref.watch(mapServiceProvider);
final markers = await mapService.getMarkers(bounds);
final features = List.filled(markers.length, const <String, dynamic>{});
@@ -131,7 +131,7 @@ final mapMarkerProvider = FutureProvider.family<Map<String, dynamic>, LatLngBoun
'id': marker.assetId,
'geometry': {
'type': 'Point',
'coordinates': [marker.location.longitude, marker.location.latitude],
'coordinates': [marker.location.lon, marker.location.lat],
},
};
}
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
@@ -20,27 +19,10 @@ 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,
};
}
}
import 'package:maplibre/maplibre.dart';
class DriftMap extends ConsumerStatefulWidget {
final LatLng? initialLocation;
final Geographic? initialLocation;
const DriftMap({super.key, this.initialLocation});
@@ -49,7 +31,7 @@ class DriftMap extends ConsumerStatefulWidget {
}
class _DriftMapState extends ConsumerState<DriftMap> {
MapLibreMapController? mapController;
MapController? mapController;
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
@@ -69,7 +51,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
super.dispose();
}
void onMapCreated(MapLibreMapController controller) {
void onMapCreated(MapController controller) {
mapController = controller;
}
@@ -81,43 +63,23 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return;
}
await controller.addSource(
MapUtils.defaultSourceId,
const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
await controller.style!.addSource(
GeoJsonSource(id: MapUtils.defaultSourceId, data: jsonEncode({'type': 'FeatureCollection', 'features': []})),
);
if (Platform.isAndroid) {
await controller.addCircleLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
const CircleLayerProperties(
circleRadius: 10,
circleColor: "rgba(150,86,34,0.7)",
circleBlur: 1.0,
circleOpacity: 0.7,
circleStrokeWidth: 0.1,
circleStrokeColor: "rgba(203,46,19,0.5)",
circleStrokeOpacity: 0.7,
),
);
}
if (Platform.isIOS) {
await controller.addHeatmapLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatmapLayerProperties,
);
}
await controller.style!.addLayer(
const HeatmapStyleLayer(
id: MapUtils.defaultHeatMapLayerId,
sourceId: MapUtils.defaultSourceId,
paint: MapUtils.defaultHeatmapLayerPaint,
),
);
_debouncer.run(() => setBounds(forceReload: true));
controller.addListener(onMapMoved);
}
void onMapMoved() {
if (mapController!.isCameraMoving || !mounted) {
return;
}
void onMapEvent(MapEvent event) {
if (event is! MapEventCameraIdle || !mounted) return;
_debouncer.run(setBounds);
}
@@ -136,7 +98,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return;
}
final bounds = await controller.getVisibleRegion();
final bounds = controller.getVisibleRegion();
unawaited(
_reloadMutex.run(() async {
if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
@@ -153,7 +115,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return;
}
await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers);
await controller.style!.updateGeoJsonSource(id: MapUtils.defaultSourceId, data: jsonEncode(markers));
}
Future<void> onZoomToLocation() async {
@@ -173,8 +135,9 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final controller = mapController;
if (controller != null && location != null) {
await controller.animateCamera(
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
center: Geographic(lat: location.latitude, lon: location.longitude),
zoom: MapUtils.mapZoomToAssetLevel,
nativeDuration: Durations.extralong2,
);
}
}
@@ -183,7 +146,12 @@ class _DriftMapState extends ConsumerState<DriftMap> {
Widget build(BuildContext context) {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_Map(
initialLocation: widget.initialLocation,
onMapCreated: onMapCreated,
onMapReady: onMapReady,
onMapEvent: onMapEvent,
),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
@@ -192,13 +160,13 @@ class _DriftMapState extends ConsumerState<DriftMap> {
}
class _Map extends StatelessWidget {
final LatLng? initialLocation;
final Geographic? initialLocation;
const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady});
final MapCreatedCallback onMapCreated;
const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady, required this.onMapEvent});
final void Function(MapController) onMapCreated;
final VoidCallback onMapReady;
final void Function(MapEvent) onMapEvent;
@override
Widget build(BuildContext context) {
@@ -206,16 +174,15 @@ class _Map extends StatelessWidget {
return MapThemeOverride(
mapBuilder: (style) => style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: initialLocation == null
? const CameraPosition(target: LatLng(0, 0), zoom: 0)
: CameraPosition(target: initialLocation, zoom: MapUtils.mapZoomToAssetLevel),
compassEnabled: false,
rotateGesturesEnabled: false,
styleString: style,
options: MapOptions(
initCenter: initialLocation ?? const Geographic(lat: 0, lon: 0),
initZoom: initialLocation == null ? 0 : MapUtils.mapZoomToAssetLevel,
initStyle: style,
gestures: const MapGestures.all(rotate: false),
),
onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: const Point(8, kToolbarHeight),
onStyleLoaded: (_) => onMapReady(),
onEvent: onMapEvent,
),
),
);
@@ -5,7 +5,6 @@ import 'package:geolocator/geolocator.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapUtils {
static final Logger _logger = Logger("MapUtils");
@@ -13,49 +12,37 @@ class MapUtils {
static const mapZoomToAssetLevel = 12.0;
static const defaultSourceId = 'asset-map-markers';
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
static var markerCompleter = Completer()..complete();
static const defaultCircleLayerLayerProperties = CircleLayerProperties(
circleRadius: 10,
circleColor: "rgba(150,86,34,0.7)",
circleBlur: 1.0,
circleOpacity: 0.7,
circleStrokeWidth: 0.1,
circleStrokeColor: "rgba(203,46,19,0.5)",
circleStrokeOpacity: 0.7,
);
static const defaultHeatmapLayerProperties = HeatmapLayerProperties(
heatmapColor: [
Expressions.interpolate,
["linear"],
["heatmap-density"],
static const defaultHeatmapLayerPaint = <String, Object>{
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0.0,
"rgba(103,58,183,0.0)",
'rgba(103,58,183,0.0)',
0.3,
"rgb(103,58,183)",
'rgb(103,58,183)',
0.5,
"rgb(33,149,243)",
'rgb(33,149,243)',
0.7,
"rgb(76,175,79)",
'rgb(76,175,79)',
0.95,
"rgb(255,235,59)",
'rgb(255,235,59)',
1.0,
"rgb(255,86,34)",
'rgb(255,86,34)',
],
heatmapIntensity: [
Expressions.interpolate,
["linear"],
[Expressions.zoom],
'heatmap-intensity': [
'interpolate',
['linear'],
['zoom'],
0,
0.5,
9,
2,
],
heatmapRadius: [
Expressions.interpolate,
["linear"],
[Expressions.zoom],
'heatmap-radius': [
'interpolate',
['linear'],
['zoom'],
0,
4,
4,
@@ -63,8 +50,8 @@ class MapUtils {
9,
16,
],
heatmapOpacity: 0.7,
);
'heatmap-opacity': 0.7,
};
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation({
required BuildContext context,