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
+2 -2
View File
@@ -1,7 +1,7 @@
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
class Marker {
final LatLng location;
final Geographic location;
final String assetId;
const Marker({required this.location, required this.assetId});
+3 -3
View File
@@ -1,9 +1,9 @@
import 'package:immich_mobile/domain/models/map.model.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart' hide Marker;
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
typedef MapMarkerSource = Future<List<Marker>> Function(LngLatBounds? bounds);
typedef MapQuery = ({MapMarkerSource markerSource});
@@ -21,5 +21,5 @@ class MapService {
MapService(MapQuery query) : _markerSource = query.markerSource;
Future<List<Marker>> Function(LatLngBounds? bounds) get getMarkers => _markerSource;
Future<List<Marker>> Function(LngLatBounds? bounds) get getMarkers => _markerSource;
}
@@ -1,20 +1,23 @@
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
extension WithinBounds on LatLngBounds {
extension WithinBounds on LngLatBounds {
/// Checks whether [point] is inside bounds
bool contains(LatLng point) {
final sw = point;
final ne = point;
return containsBounds(LatLngBounds(southwest: sw, northeast: ne));
bool contains(Geographic point) {
return containsBounds(
LngLatBounds(
longitudeWest: point.lon,
longitudeEast: point.lon,
latitudeSouth: point.lat,
latitudeNorth: point.lat,
),
);
}
/// Checks whether [bounds] is contained inside bounds
bool containsBounds(LatLngBounds bounds) {
final sw = bounds.southwest;
final ne = bounds.northeast;
return (sw.latitude >= southwest.latitude) &&
(ne.latitude <= northeast.latitude) &&
(sw.longitude >= southwest.longitude) &&
(ne.longitude <= northeast.longitude);
bool containsBounds(LngLatBounds bounds) {
return (bounds.latitudeSouth >= latitudeSouth) &&
(bounds.latitudeNorth <= latitudeNorth) &&
(bounds.longitudeWest >= longitudeWest) &&
(bounds.longitudeEast <= longitudeEast);
}
}
@@ -1,19 +1,19 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
extension MapMarkers on MapLibreMapController {
extension MapMarkers on MapController {
static var _completer = Completer()..complete();
Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async {
return addSource(
MapUtils.defaultSourceId,
GeojsonSourceProperties(data: MapUtils.generateGeoJsonForMarkers(markers.toList())),
return style!.addSource(
GeoJsonSource(
id: MapUtils.defaultSourceId,
data: jsonEncode(MapUtils.generateGeoJsonForMarkers(markers.toList())),
),
);
}
@@ -27,63 +27,28 @@ extension MapMarkers on MapLibreMapController {
// !! 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 getLayerIds();
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
await removeLayer(MapUtils.defaultHeatMapLayerId);
try {
await style!.removeLayer(MapUtils.defaultHeatMapLayerId);
} catch (_) {
// Layer may not exist
}
final existingSources = await getSourceIds();
if (existingSources.contains(MapUtils.defaultSourceId)) {
await removeSource(MapUtils.defaultSourceId);
try {
await style!.removeSource(MapUtils.defaultSourceId);
} catch (_) {
// Source may not exist
}
await addGeoJSONSourceForMarkers(markers);
if (Platform.isAndroid) {
await 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 addHeatmapLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatMapLayerProperties,
);
}
await style!.addLayer(
const HeatmapStyleLayer(
id: MapUtils.defaultHeatMapLayerId,
sourceId: MapUtils.defaultSourceId,
paint: MapUtils.defaultHeatMapLayerPaint,
),
);
_completer.complete();
}
Future<Symbol?> addMarkerAtLatLng(LatLng centre) async {
// no marker is displayed if asset-path is incorrect
try {
final ByteData bytes = await rootBundle.load("assets/location-pin.png");
await addImage("mapMarker", bytes.buffer.asUint8List());
return addSymbol(SymbolOptions(geometry: centre, iconImage: "mapMarker", iconSize: 0.15, iconAnchor: "bottom"));
} finally {
// no-op
}
}
Future<LatLngBounds> getBoundsFromPoint(Point<double> point, double distance) async {
final southWestPx = Point(point.x - distance, point.y + distance);
final northEastPx = Point(point.x + distance, point.y - distance);
final southWest = await toLatLng(southWestPx);
final northEast = await toLatLng(northEastPx);
return LatLngBounds(southwest: southWest, northeast: northEast);
}
}
@@ -1,3 +1,4 @@
// ignore_for_file: experimental_member_use
import 'dart:async';
import 'package:drift/drift.dart';
@@ -6,7 +6,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart' hide Marker;
class DriftMapRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -42,7 +42,7 @@ class DriftMapRepository extends DriftDatabaseRepository {
Future<List<Marker>> _watchMapMarker({
Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter,
LatLngBounds? bounds,
LngLatBounds? bounds,
}) async {
final assetId = _db.remoteExifEntity.assetId;
final latitude = _db.remoteExifEntity.latitude;
@@ -66,20 +66,21 @@ class DriftMapRepository extends DriftDatabaseRepository {
final rows = await query.get();
return List.generate(rows.length, (i) {
final row = rows[i];
return Marker(assetId: row.read(assetId)!, location: LatLng(row.read(latitude)!, row.read(longitude)!));
return Marker(
assetId: row.read(assetId)!,
location: Geographic(lat: row.read(latitude)!, lon: row.read(longitude)!),
);
}, growable: false);
}
}
extension MapBounds on $RemoteExifEntityTable {
Expression<bool> inBounds(LatLngBounds bounds) {
final southwest = bounds.southwest;
final northeast = bounds.northeast;
final latInBounds = latitude.isBetweenValues(southwest.latitude, northeast.latitude);
final longInBounds = southwest.longitude <= northeast.longitude
? longitude.isBetweenValues(southwest.longitude, northeast.longitude)
: (longitude.isBiggerOrEqualValue(southwest.longitude) | longitude.isSmallerOrEqualValue(northeast.longitude));
Expression<bool> inBounds(LngLatBounds bounds) {
final latInBounds = latitude.isBetweenValues(bounds.latitudeSouth, bounds.latitudeNorth);
final longInBounds = bounds.longitudeWest <= bounds.longitudeEast
? longitude.isBetweenValues(bounds.longitudeWest, bounds.longitudeEast)
: (longitude.isBiggerOrEqualValue(bounds.longitudeWest) |
longitude.isSmallerOrEqualValue(bounds.longitudeEast));
return latInBounds & longInBounds;
}
}
@@ -8,7 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -170,12 +170,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> updateLocation(List<String> ids, LatLng location) {
Future<void> updateLocation(List<String> ids, Geographic location) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(latitude: Value(location.latitude), longitude: Value(location.longitude)),
RemoteExifEntityCompanion(latitude: Value(location.lat), longitude: Value(location.lon)),
where: (e) => e.assetId.equals(id),
);
}
@@ -12,11 +12,11 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
import 'package:stream_transform/stream_transform.dart';
class TimelineMapOptions {
final LatLngBounds bounds;
final LngLatBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
+4 -4
View File
@@ -1,16 +1,16 @@
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
import 'package:openapi/api.dart';
class MapMarker {
final LatLng latLng;
final Geographic latLng;
final String assetRemoteId;
const MapMarker({required this.latLng, required this.assetRemoteId});
MapMarker copyWith({LatLng? latLng, String? assetRemoteId}) {
MapMarker copyWith({Geographic? latLng, String? assetRemoteId}) {
return MapMarker(latLng: latLng ?? this.latLng, assetRemoteId: assetRemoteId ?? this.assetRemoteId);
}
MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = LatLng(dto.lat, dto.lon), assetRemoteId = dto.id;
MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = Geographic(lat: dto.lat, lon: dto.lon), assetRemoteId = dto.id;
@override
String toString() => 'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)';
+2 -2
View File
@@ -17,7 +17,7 @@ import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
@RoutePage()
class LibraryPage extends ConsumerWidget {
@@ -325,7 +325,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,
),
@@ -15,12 +15,12 @@ import 'package:immich_mobile/providers/search/search_page_state.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 PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key, this.currentLocation});
final LatLng? currentLocation;
final Geographic? currentLocation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider);
@@ -61,7 +61,7 @@ class PlacesCollectionPage extends HookConsumerWidget {
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(MapRoute(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,
),
+115 -128
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
@@ -12,8 +11,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_event.model.dart' as app;
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
@@ -26,25 +26,25 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
@RoutePage()
class MapPage extends HookConsumerWidget {
const MapPage({super.key, this.initialLocation});
final LatLng? initialLocation;
final Geographic? initialLocation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapController = useRef<MapLibreMapController?>(null);
final mapController = useRef<MapController?>(null);
final markers = useRef<List<MapMarker>>([]);
final markersInBounds = useRef<List<MapMarker>>([]);
final bottomSheetStreamController = useStreamController<MapEvent>();
final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null);
final bottomSheetStreamController = useStreamController<app.MapEvent>();
final selectedMarker = useValueNotifier<MapMarker?>(null);
final assetsDebouncer = useDebouncer();
final layerDebouncer = useDebouncer(interval: const Duration(seconds: 1));
final isLoading = useProcessingOverlay();
@@ -55,19 +55,17 @@ class MapPage extends HookConsumerWidget {
// updates the markersInBounds value with the map markers that are visible in the current
// map camera bounds
Future<void> updateAssetsInBounds() async {
// Guard map not created
if (mapController.value == null) {
return;
}
void updateAssetsInBounds() {
if (mapController.value == null) return;
final bounds = await mapController.value!.getVisibleRegion();
final bounds = mapController.value!.getVisibleRegion();
final inBounds = markers.value
.where((m) => bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)))
.where((m) => bounds.contains(Geographic(lat: m.latLng.lat, lon: m.latLng.lon)))
.toList();
// Notify bottom sheet to update asset grid only when there are new assets
if (markersInBounds.value.length != inBounds.length) {
bottomSheetStreamController.add(MapAssetsInBoundsUpdated(inBounds.map((e) => e.assetRemoteId).toList()));
bottomSheetStreamController.add(app.MapAssetsInBoundsUpdated(inBounds.map((e) => e.assetRemoteId).toList()));
}
markersInBounds.value = inBounds;
}
@@ -99,57 +97,67 @@ class MapPage extends HookConsumerWidget {
// Refetch markers when map state is changed
ref.listen(mapStateNotifierProvider, (_, current) {
if (current.shouldRefetchMarkers) {
markerDebouncer.run(() {
ref.invalidate(mapMarkersProvider);
// Reset marker
selectedMarker.value = null;
loadMarkers();
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
});
}
if (!current.shouldRefetchMarkers) return;
markerDebouncer.run(() {
ref.invalidate(mapMarkersProvider);
// Reset marker
selectedMarker.value = null;
loadMarkers();
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
});
});
// updates the selected markers position based on the current map camera
Future<void> updateAssetMarkerPosition(MapMarker marker, {bool shouldAnimate = true}) async {
final assetPoint = await mapController.value!.toScreenLocation(marker.latLng);
selectedMarker.value = _AssetMarkerMeta(point: assetPoint, marker: marker, shouldAnimate: shouldAnimate);
(assetPoint, marker, shouldAnimate);
void selectMarker(MapMarker marker) {
selectedMarker.value = marker;
}
// finds the nearest asset marker from the tap point and store it as the selectedMarker
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
// Guard map not created
if (mapController.value == null) {
return;
}
final latlngBound = await mapController.value!.getBoundsFromPoint(point, 50);
final marker = markersInBounds.value.firstWhereOrNull(
(m) => latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
void onMarkerClicked(Offset point) {
if (mapController.value == null) return;
final features = mapController.value!.featuresInRect(
Rect.fromCircle(center: point, radius: 50),
layerIds: [MapUtils.defaultHeatMapLayerId],
);
final featureId = features.firstOrNull?.id?.toString();
final marker = featureId != null
? markersInBounds.value.firstWhereOrNull((m) => m.assetRemoteId == featureId)
: null;
if (marker != null) {
await updateAssetMarkerPosition(marker);
} else {
// If no asset was previously selected and no new asset is available, close the bottom sheet
if (selectedMarker.value == null) {
bottomSheetStreamController.add(const MapCloseBottomSheet());
}
selectedMarker.value = null;
selectMarker(marker);
return;
}
if (selectedMarker.value == null) {
// If no asset was previously selected and no new asset is available,
// close the bottom sheet.
bottomSheetStreamController.add(const app.MapCloseBottomSheet());
return;
}
selectedMarker.value = null;
}
void onMapCreated(MapLibreMapController controller) async {
void onMapCreated(MapController controller) {
mapController.value = controller;
controller.addListener(() {
if (controller.isCameraMoving && selectedMarker.value != null) {
updateAssetMarkerPosition(selectedMarker.value!.marker, shouldAnimate: false);
}
});
}
void onMapEvent(MapEvent event) {
switch (event) {
case MapEventClick():
onMarkerClicked(event.screenPoint);
case MapEventCameraIdle():
assetsDebouncer.run(updateAssetsInBounds);
default:
}
}
Future<void> onMarkerTapped() async {
final assetId = selectedMarker.value?.marker.assetRemoteId;
final assetId = selectedMarker.value?.assetRemoteId;
if (assetId == null) {
return;
}
@@ -171,14 +179,10 @@ class MapPage extends HookConsumerWidget {
/// BOTTOM SHEET CALLBACKS
Future<void> onMapMoved() async {
assetsDebouncer.run(updateAssetsInBounds);
}
void onBottomSheetScrolled(String assetRemoteId) {
final assetMarker = markersInBounds.value.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
if (assetMarker != null) {
updateAssetMarkerPosition(assetMarker);
selectMarker(assetMarker);
}
}
@@ -187,10 +191,11 @@ class MapPage extends HookConsumerWidget {
if (mapController.value != null && assetMarker != null) {
// Offset the latitude a little to show the marker just above the viewports center
final offset = context.isMobile ? 0.02 : 0;
final latlng = LatLng(assetMarker.latLng.latitude - offset, assetMarker.latLng.longitude);
final latlng = Geographic(lat: assetMarker.latLng.lat - offset, lon: assetMarker.latLng.lon);
mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(latlng, mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
center: latlng,
zoom: mapZoomToAssetLevel,
nativeDuration: Durations.extralong2,
);
}
}
@@ -211,8 +216,9 @@ class MapPage extends HookConsumerWidget {
if (mapController.value != null && location != null) {
await mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
center: Geographic(lat: location.latitude, lon: location.longitude),
zoom: mapZoomToAssetLevel,
nativeDuration: Durations.extralong2,
);
}
}
@@ -234,9 +240,8 @@ class MapPage extends HookConsumerWidget {
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
onMapClicked: onMarkerClicked,
onStyleLoaded: reloadLayers,
onMapEvent: onMapEvent,
onStyleLoaded: (_) => reloadLayers(),
onMarkerTapped: onMarkerTapped,
),
// Should be a part of the body and not scaffold::bottomsheet for the
@@ -266,9 +271,8 @@ class MapPage extends HookConsumerWidget {
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
onMapClicked: onMarkerClicked,
onStyleLoaded: reloadLayers,
onMapEvent: onMapEvent,
onStyleLoaded: (_) => reloadLayers(),
onMarkerTapped: onMarkerTapped,
),
Positioned(
@@ -302,32 +306,19 @@ class MapPage extends HookConsumerWidget {
}
}
class _AssetMarkerMeta {
final Point<num> point;
final MapMarker marker;
final bool shouldAnimate;
const _AssetMarkerMeta({required this.point, required this.marker, required this.shouldAnimate});
@override
String toString() => '_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
}
class _MapWithMarker extends StatelessWidget {
final AsyncValue<String> style;
final MapCreatedCallback onMapCreated;
final OnCameraIdleCallback onMapMoved;
final OnMapClickCallback onMapClicked;
final OnStyleLoadedCallback onStyleLoaded;
final void Function(MapController) onMapCreated;
final void Function(MapEvent) onMapEvent;
final void Function(StyleController) onStyleLoaded;
final Function()? onMarkerTapped;
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
final LatLng? initialLocation;
final ValueNotifier<MapMarker?> selectedMarker;
final Geographic? initialLocation;
const _MapWithMarker({
required this.style,
required this.onMapCreated,
required this.onMapMoved,
required this.onMapClicked,
required this.onMapEvent,
required this.onStyleLoaded,
required this.selectedMarker,
this.onMarkerTapped,
@@ -336,48 +327,44 @@ class _MapWithMarker extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (ctx, constraints) => SizedBox(
height: constraints.maxHeight,
width: constraints.maxWidth,
child: Stack(
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
attributionButtonMargins: const Point(8, kToolbarHeight),
initialCameraPosition: CameraPosition(
target: initialLocation ?? const LatLng(0, 0),
zoom: initialLocation != null ? 12 : 0,
),
styleString: style,
// This is needed to update the selectedMarker's position on map camera updates
// The changes are notified through the mapController ValueListener which is added in [onMapCreated]
trackCameraPosition: true,
onMapCreated: onMapCreated,
onCameraIdle: onMapMoved,
onMapClick: onMapClicked,
onStyleLoadedCallback: onStyleLoaded,
tiltGesturesEnabled: false,
dragEnabled: false,
myLocationEnabled: false,
attributionButtonPosition: AttributionButtonPosition.topRight,
rotateGesturesEnabled: false,
),
),
ValueListenableBuilder(
valueListenable: selectedMarker,
builder: (ctx, value, _) => value != null
? PositionedAssetMarkerIcon(
point: value.point,
assetRemoteId: value.marker.assetRemoteId,
assetThumbhash: '',
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
onTap: onMarkerTapped,
)
: const SizedBox.shrink(),
),
],
return style.widgetWhen(
onData: (style) => MapLibreMap(
options: MapOptions(
initCenter: initialLocation ?? const Geographic(lat: 0, lon: 0),
initZoom: initialLocation != null ? 12 : 0,
initStyle: style,
gestures: const MapGestures.all(pitch: false, rotate: false),
),
onMapCreated: onMapCreated,
onStyleLoaded: onStyleLoaded,
onEvent: onMapEvent,
children: [
ValueListenableBuilder<MapMarker?>(
valueListenable: selectedMarker,
builder: (ctx, marker, _) => marker != null
? WidgetLayer(
markers: [
Marker(
point: marker.latLng,
size: const Size(100, 100),
alignment: Alignment.bottomCenter,
child: GestureDetector(
onTap: () => onMarkerTapped?.call(),
child: SizedBox.square(
dimension: 100,
child: AssetMarkerIcon(
id: marker.assetRemoteId,
thumbhash: '',
key: Key(marker.assetRemoteId),
),
),
),
),
],
)
: const SizedBox.shrink(),
),
],
),
);
}
@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -7,36 +5,34 @@ 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/utils/map_utils.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
@RoutePage()
class MapLocationPickerPage extends HookConsumerWidget {
final LatLng initialLatLng;
final Geographic initialLatLng;
const MapLocationPickerPage({super.key, this.initialLatLng = const LatLng(0, 0)});
const MapLocationPickerPage({super.key, this.initialLatLng = const Geographic(lat: 0, lon: 0)});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedLatLng = useValueNotifier<LatLng>(initialLatLng);
final controller = useRef<MapLibreMapController?>(null);
final marker = useRef<Symbol?>(null);
final selectedLatLng = useValueNotifier<Geographic>(initialLatLng);
final currentLatLng = useValueListenable(selectedLatLng);
final controller = useRef<MapController?>(null);
Future<void> onStyleLoaded() async {
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
Future<void> onStyleLoaded(StyleController style) async {
await style.addImageFromAssets(id: 'mapMarker', asset: 'assets/location-pin.png');
}
Future<void> onMapClick(Point<num> _, LatLng centre) async {
selectedLatLng.value = centre;
await controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
if (marker.value != null) {
await controller.value?.updateSymbol(marker.value!, SymbolOptions(geometry: centre));
}
void onEvent(MapEvent event) {
if (event is! MapEventClick) return;
selectedLatLng.value = event.point;
controller.value?.animateCamera(center: event.point);
}
void onClose([LatLng? selected]) {
void onClose([Geographic? selected]) {
context.maybePop(selected);
}
@@ -47,9 +43,9 @@ class MapLocationPickerPage extends HookConsumerWidget {
return;
}
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
var currentLatLng = Geographic(lat: currentLocation.latitude, lon: currentLocation.longitude);
selectedLatLng.value = currentLatLng;
await controller.value?.animateCamera(CameraUpdate.newLatLngZoom(currentLatLng, 12));
await controller.value?.animateCamera(center: currentLatLng, zoom: 12);
}
return MapThemeOverride(
@@ -66,18 +62,24 @@ class MapLocationPickerPage extends HookConsumerWidget {
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(40), bottomRight: Radius.circular(40)),
),
child: MapLibreMap(
initialCameraPosition: CameraPosition(
target: initialLatLng,
zoom: (initialLatLng.latitude == 0 && initialLatLng.longitude == 0) ? 1 : 12,
options: MapOptions(
initCenter: initialLatLng,
initZoom: (initialLatLng.lat == 0 && initialLatLng.lon == 0) ? 1 : 12,
initStyle: style,
gestures: const MapGestures.all(pitch: false),
),
styleString: style,
onMapCreated: (mapController) => controller.value = mapController,
onStyleLoadedCallback: onStyleLoaded,
onMapClick: onMapClick,
dragEnabled: false,
tiltGesturesEnabled: false,
myLocationEnabled: false,
attributionButtonMargins: const Point(20, 15),
onStyleLoaded: onStyleLoaded,
onEvent: onEvent,
layers: [
MarkerLayer(
points: [Feature(geometry: Point(currentLatLng))],
iconImage: 'mapMarker',
iconSize: 0.15,
iconAnchor: IconAnchor.bottom,
iconAllowOverlap: true,
),
],
),
),
),
@@ -117,7 +119,7 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget {
}
class _BottomBar extends StatelessWidget {
final ValueNotifier<LatLng> selectedLatLng;
final ValueNotifier<Geographic> selectedLatLng;
final Function() onUseLocation;
final Function() onGetCurrentLocation;
@@ -140,8 +142,7 @@ class _BottomBar extends StatelessWidget {
const SizedBox(width: 15),
ValueListenableBuilder(
valueListenable: selectedLatLng,
builder: (_, value, __) =>
Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"),
builder: (_, value, __) => Text("${value.lat.toStringAsFixed(4)}, ${value.lon.toStringAsFixed(4)}"),
),
],
),
@@ -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,
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
import 'package:openapi/api.dart';
final assetApiRepositoryProvider = Provider(
@@ -62,8 +62,8 @@ class AssetApiRepository extends ApiRepository {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: isFavorite));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
Future<void> updateLocation(List<String> ids, Geographic location) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.lat, longitude: location.lon));
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
+1 -1
View File
@@ -123,7 +123,7 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/local_auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
part 'router.gr.dart';
+19 -16
View File
@@ -1226,7 +1226,7 @@ class DriftLockedFolderRoute extends PageRouteInfo<void> {
class DriftMapRoute extends PageRouteInfo<DriftMapRouteArgs> {
DriftMapRoute({
Key? key,
LatLng? initialLocation,
Geographic? initialLocation,
List<PageRouteInfo>? children,
}) : super(
DriftMapRoute.name,
@@ -1252,7 +1252,7 @@ class DriftMapRouteArgs {
final Key? key;
final LatLng? initialLocation;
final Geographic? initialLocation;
@override
String toString() {
@@ -1461,7 +1461,7 @@ class DriftPlaceDetailRouteArgs {
class DriftPlaceRoute extends PageRouteInfo<DriftPlaceRouteArgs> {
DriftPlaceRoute({
Key? key,
LatLng? currentLocation,
Geographic? currentLocation,
List<PageRouteInfo>? children,
}) : super(
DriftPlaceRoute.name,
@@ -1490,7 +1490,7 @@ class DriftPlaceRouteArgs {
final Key? key;
final LatLng? currentLocation;
final Geographic? currentLocation;
@override
String toString() {
@@ -2011,7 +2011,7 @@ class MainTimelineRoute extends PageRouteInfo<void> {
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
MapLocationPickerRoute({
Key? key,
LatLng initialLatLng = const LatLng(0, 0),
Geographic initialLatLng = const Geographic(lat: 0, lon: 0),
List<PageRouteInfo>? children,
}) : super(
MapLocationPickerRoute.name,
@@ -2041,12 +2041,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
class MapLocationPickerRouteArgs {
const MapLocationPickerRouteArgs({
this.key,
this.initialLatLng = const LatLng(0, 0),
this.initialLatLng = const Geographic(lat: 0, lon: 0),
});
final Key? key;
final LatLng initialLatLng;
final Geographic initialLatLng;
@override
String toString() {
@@ -2057,12 +2057,15 @@ class MapLocationPickerRouteArgs {
/// generated route for
/// [MapPage]
class MapRoute extends PageRouteInfo<MapRouteArgs> {
MapRoute({Key? key, LatLng? initialLocation, List<PageRouteInfo>? children})
: super(
MapRoute.name,
args: MapRouteArgs(key: key, initialLocation: initialLocation),
initialChildren: children,
);
MapRoute({
Key? key,
Geographic? initialLocation,
List<PageRouteInfo>? children,
}) : super(
MapRoute.name,
args: MapRouteArgs(key: key, initialLocation: initialLocation),
initialChildren: children,
);
static const String name = 'MapRoute';
@@ -2082,7 +2085,7 @@ class MapRouteArgs {
final Key? key;
final LatLng? initialLocation;
final Geographic? initialLocation;
@override
String toString() {
@@ -2403,7 +2406,7 @@ class PinAuthRouteArgs {
class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
PlacesCollectionRoute({
Key? key,
LatLng? currentLocation,
Geographic? currentLocation,
List<PageRouteInfo>? children,
}) : super(
PlacesCollectionRoute.name,
@@ -2435,7 +2438,7 @@ class PlacesCollectionRouteArgs {
final Key? key;
final LatLng? currentLocation;
final Geographic? currentLocation;
@override
String toString() {
+3 -3
View File
@@ -22,7 +22,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
import 'package:maplibre/maplibre.dart' as maplibre;
import 'package:riverpod_annotation/riverpod_annotation.dart';
final actionServiceProvider = Provider<ActionService>(
@@ -131,12 +131,12 @@ class ActionService {
}
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
maplibre.LatLng? initialLatLng;
maplibre.Geographic? initialLatLng;
if (remoteIds.length == 1) {
final exif = await _remoteAssetRepository.getExif(remoteIds[0]);
if (exif?.latitude != null && exif?.longitude != null) {
initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!);
initialLatLng = maplibre.Geographic(lat: exif!.latitude!, lon: exif.longitude!);
}
}
+4 -4
View File
@@ -23,7 +23,7 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@@ -236,12 +236,12 @@ class AssetService {
}
}
Future<List<Asset>?> changeLocation(List<Asset> assets, LatLng location) async {
Future<List<Asset>?> changeLocation(List<Asset> assets, Geographic location) async {
try {
await updateAssets(assets, UpdateAssetDto(latitude: location.latitude, longitude: location.longitude));
await updateAssets(assets, UpdateAssetDto(latitude: location.lat, longitude: location.lon));
for (var element in assets) {
element.exifInfo = element.exifInfo?.copyWith(latitude: location.latitude, longitude: location.longitude);
element.exifInfo = element.exifInfo?.copyWith(latitude: location.lat, longitude: location.lon);
}
await _syncService.upsertAssetsWithExif(assets);
+1 -10
View File
@@ -1,23 +1,14 @@
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapService with ErrorLoggerMixin {
final ApiService _apiService;
@override
final logger = Logger("MapService");
MapService(this._apiService) {
_setMapUserAgentHeader();
}
Future<void> _setMapUserAgentHeader() async {
final userAgent = await getUserAgentString();
await setHttpHeaders({'User-Agent': userAgent});
}
MapService(this._apiService);
Future<Iterable<MapMarker>> getMapMarkers({
bool? isFavorite,
+32 -26
View File
@@ -6,7 +6,6 @@ import 'package:geolocator/geolocator.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapUtils {
const MapUtils._();
@@ -15,46 +14,53 @@ class MapUtils {
static const defaultSourceId = 'asset-map-markers';
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
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],
0, 0.5,
9, 2,
'heatmap-intensity': [
'interpolate',
['linear'],
['zoom'],
0,
0.5,
9,
2,
],
heatmapRadius: [
Expressions.interpolate, ["linear"], //
[Expressions.zoom],
0, 4,
4, 8,
9, 16,
'heatmap-radius': [
'interpolate',
['linear'],
['zoom'],
0,
4,
4,
8,
9,
16,
],
heatmapOpacity: 0.7,
);
'heatmap-opacity': 0.7,
};
static Map<String, dynamic> _addFeature(MapMarker marker) => {
'type': 'Feature',
'id': marker.assetRemoteId,
'geometry': {
'type': 'Point',
'coordinates': [marker.latLng.longitude, marker.latLng.latitude],
'coordinates': [marker.latLng.lon, marker.latLng.lat],
},
};
+3 -3
View File
@@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:immich_mobile/widgets/common/share_dialog.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
void handleShareAssets(WidgetRef ref, BuildContext context, Iterable<Asset> selection) {
showDialog(
@@ -105,12 +105,12 @@ Future<void> handleEditDateTime(WidgetRef ref, BuildContext context, List<Asset>
}
Future<void> handleEditLocation(WidgetRef ref, BuildContext context, List<Asset> selection) async {
LatLng? initialLatLng;
Geographic? initialLatLng;
if (selection.length == 1) {
final asset = selection.first;
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
if (assetWithExif.exifInfo?.latitude != null && assetWithExif.exifInfo?.longitude != null) {
initialLatLng = LatLng(assetWithExif.exifInfo!.latitude!, assetWithExif.exifInfo!.longitude!);
initialLatLng = Geographic(lat: assetWithExif.exifInfo!.latitude!, lon: assetWithExif.exifInfo!.longitude!);
}
}
@@ -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,
),
),
);
}