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
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
import 'package:url_launcher/url_launcher.dart';
class ExifMap extends StatelessWidget {
@@ -15,7 +15,7 @@ class ExifMap extends StatelessWidget {
// reusing this component
final String? markerId;
final String? markerAssetThumbhash;
final MapCreatedCallback? onMapCreated;
final void Function(MapController)? onMapCreated;
const ExifMap({
super.key,
@@ -66,7 +66,7 @@ class ExifMap extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
return MapThumbnail(
centre: LatLng(exifInfo.latitude ?? 0, exifInfo.longitude ?? 0),
centre: Geographic(lat: exifInfo.latitude ?? 0, lon: exifInfo.longitude ?? 0),
height: 150,
width: constraints.maxWidth,
zoom: 12.0,
+10 -10
View File
@@ -6,10 +6,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initialLatLng}) {
return showDialog<LatLng?>(
Future<Geographic?> showLocationPicker({required BuildContext context, Geographic? initialLatLng}) {
return showDialog<Geographic?>(
context: context,
useRootNavigator: false,
builder: (ctx) => _LocationPicker(initialLatLng: initialLatLng),
@@ -17,7 +17,7 @@ Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initi
}
class _LocationPicker extends HookWidget {
final LatLng? initialLatLng;
final Geographic? initialLatLng;
const _LocationPicker({this.initialLatLng});
@@ -33,9 +33,9 @@ class _LocationPicker extends HookWidget {
@override
Widget build(BuildContext context) {
final latitude = useState(initialLatLng?.latitude ?? 0.0);
final longitude = useState(initialLatLng?.longitude ?? 0.0);
final latlng = LatLng(latitude.value, longitude.value);
final latitude = useState(initialLatLng?.lat ?? 0.0);
final longitude = useState(initialLatLng?.lon ?? 0.0);
final latlng = Geographic(lat: latitude.value, lon: longitude.value);
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
final latitudeController = useTextEditingController(text: latitude.value.toStringAsFixed(4));
@@ -48,10 +48,10 @@ class _LocationPicker extends HookWidget {
}, [latitude.value, longitude.value]);
Future<void> onMapTap() async {
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
final newLatLng = await context.pushRoute<Geographic?>(MapLocationPickerRoute(initialLatLng: latlng));
if (newLatLng != null) {
latitude.value = newLatLng.latitude;
longitude.value = newLatLng.longitude;
latitude.value = newLatLng.lat;
longitude.value = newLatLng.lon;
}
}
+26 -27
View File
@@ -51,36 +51,35 @@ class MapAssetGrid extends HookConsumerWidget {
final assetCache = useRef<Map<String, Asset>>({});
void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) {
final assetIds = event.assetRemoteIds;
final missingIds = <String>[];
final currentAssets = <Asset>[];
if (event is! MapAssetsInBoundsUpdated) return;
for (final id in assetIds) {
final asset = assetCache.value[id];
if (asset != null) {
currentAssets.add(asset);
} else {
missingIds.add(id);
}
final assetIds = event.assetRemoteIds;
final missingIds = <String>[];
final currentAssets = <Asset>[];
for (final id in assetIds) {
final asset = assetCache.value[id];
if (asset != null) {
currentAssets.add(asset);
} else {
missingIds.add(id);
}
// Only fetch missing assets
if (missingIds.isNotEmpty) {
final newAssets = await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
// Add new assets to cache and current list
for (final asset in newAssets) {
if (asset.remoteId != null) {
assetCache.value[asset.remoteId!] = asset;
currentAssets.add(asset);
}
}
}
assetsInBounds.value = currentAssets;
return;
}
// Only fetch missing assets
if (missingIds.isNotEmpty) {
final newAssets = await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
// Add new assets to cache and current list
for (final asset in newAssets) {
if (asset.remoteId != null) {
assetCache.value[asset.remoteId!] = asset;
currentAssets.add(asset);
}
}
}
assetsInBounds.value = currentAssets;
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
+3 -7
View File
@@ -33,13 +33,9 @@ class MapBottomSheet extends HookConsumerWidget {
final isBottomSheetOpened = useRef(false);
void handleMapEvents(MapEvent event) async {
if (event is MapCloseBottomSheet) {
await sheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
}
if (event is! MapCloseBottomSheet) return;
await sheetController.animateTo(0.1, duration: const Duration(milliseconds: 200), curve: Curves.linearToEaseOut);
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
class MapThemePicker extends StatelessWidget {
final ThemeMode themeMode;
@@ -78,7 +78,7 @@ class _BorderedMapThumbnail extends StatelessWidget {
),
child: MapThumbnail(
zoom: 2,
centre: const LatLng(47, 5),
centre: const Geographic(lat: 47, lon: 5),
onTap: (_, __) => onThemeChange(mode),
themeMode: mode,
showAttribution: false,
@@ -84,8 +84,13 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
data: _isDarkTheme
? getThemeData(colorScheme: appTheme.dark, locale: locale)
: getThemeData(colorScheme: appTheme.light, locale: locale),
child: widget.mapBuilder.call(
ref.watch(mapStateNotifierProvider.select((v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched)),
// Key on _isDarkTheme to force MapLibreMap recreation on theme change,
// since initStyle is only applied on creation.
child: KeyedSubtree(
key: ValueKey(_isDarkTheme),
child: widget.mapBuilder.call(
ref.watch(mapStateNotifierProvider.select((v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched)),
),
),
);
}
+46 -52
View File
@@ -1,14 +1,11 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
///
@@ -16,8 +13,8 @@ import 'package:maplibre_gl/maplibre_gl.dart';
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
/// [assetMarkerRemoteId] will take precedence
class MapThumbnail extends HookConsumerWidget {
final Function(Point<double>, LatLng)? onTap;
final LatLng centre;
final Function(Offset, Geographic)? onTap;
final Geographic centre;
final String? assetMarkerRemoteId;
final String? assetThumbhash;
final bool showMarkerPin;
@@ -26,7 +23,7 @@ class MapThumbnail extends HookConsumerWidget {
final double width;
final ThemeMode? themeMode;
final bool showAttribution;
final MapCreatedCallback? onCreated;
final void Function(MapController)? onCreated;
const MapThumbnail({
super.key,
@@ -45,28 +42,21 @@ class MapThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useRef<MapLibreMapController?>(null);
final styleLoaded = useState(false);
Future<void> onMapCreated(MapLibreMapController mapController) async {
controller.value = mapController;
styleLoaded.value = false;
onCreated?.call(mapController);
}
Future<void> onStyleLoaded() async {
try {
if (showMarkerPin && controller.value != null) {
await controller.value?.addMarkerAtLatLng(centre);
}
} finally {
// Calling methods on the controller after it is disposed will throw an error
// We do not have a way to check if the controller is disposed for now
// https://github.com/maplibre/flutter-maplibre-gl/issues/192
Future<void> onStyleLoaded(StyleController style) async {
if (showMarkerPin) {
await style.addImageFromAssets(id: 'mapMarker', asset: 'assets/location-pin.png');
}
styleLoaded.value = true;
}
void onEvent(MapEvent event) {
if (event is MapEventClick && onTap != null) {
onTap!(event.screenPoint, event.point);
}
}
return MapThemeOverride(
themeMode: themeMode,
mapBuilder: (style) => AnimatedContainer(
@@ -80,37 +70,41 @@ class MapThumbnail extends HookConsumerWidget {
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: Stack(
alignment: AlignmentGeometry.topCenter,
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: CameraPosition(target: centre, zoom: zoom),
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onStyleLoaded,
onMapClick: onTap,
doubleClickZoomEnabled: false,
dragEnabled: false,
zoomGesturesEnabled: false,
tiltGesturesEnabled: false,
scrollGesturesEnabled: false,
rotateGesturesEnabled: false,
myLocationEnabled: false,
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
),
child: style.widgetWhen(
onData: (style) => MapLibreMap(
options: MapOptions(
initCenter: Geographic(lat: centre.lat + 0.002, lon: centre.lon),
initZoom: zoom,
initStyle: style,
gestures: const MapGestures.none(),
),
if (assetMarkerRemoteId != null && assetThumbhash != null)
Container(
width: width,
height: height / 2,
alignment: Alignment.bottomCenter,
child: SizedBox.square(
dimension: height / 2.5,
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
onMapCreated: onCreated,
onStyleLoaded: onStyleLoaded,
onEvent: onEvent,
layers: [
if (showMarkerPin)
MarkerLayer(
points: [Feature(geometry: Point(centre))],
iconImage: 'mapMarker',
iconSize: 0.15,
iconAnchor: IconAnchor.bottom,
iconAllowOverlap: true,
),
),
],
],
children: [
if (assetMarkerRemoteId != null && assetThumbhash != null)
WidgetLayer(
markers: [
Marker(
point: centre,
size: Size.square(height / 2),
alignment: Alignment.bottomCenter,
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
),
],
),
],
),
),
),
),
@@ -1,43 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final String assetThumbhash;
final double size;
final int durationInMilliseconds;
final Function()? onTap;
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
required this.assetThumbhash,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final ratio = Platform.isIOS ? 1.0 : context.devicePixelRatio;
return AnimatedPositioned(
left: point.x / ratio - size / 2,
top: point.y / ratio - size,
duration: Duration(milliseconds: durationInMilliseconds),
child: GestureDetector(
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
}
}
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
class SearchMapThumbnail extends StatelessWidget {
const SearchMapThumbnail({super.key, this.size = 60.0});
@@ -20,7 +20,13 @@ class SearchMapThumbnail extends StatelessWidget {
context.pushRoute(MapRoute());
},
child: IgnorePointer(
child: MapThumbnail(zoom: 2, centre: const LatLng(47, 5), height: size, width: size, showAttribution: false),
child: MapThumbnail(
zoom: 2,
centre: const Geographic(lat: 47, lon: 5),
height: size,
width: size,
showAttribution: false,
),
),
);
}