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
+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)),
),
),
);
}
}