Compare commits

...

1 Commits

Author SHA1 Message Date
Thomas Way
c087b7c063 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.
2026-02-21 01:17:06 +00:00
46 changed files with 666 additions and 651 deletions

View File

@@ -15,7 +15,7 @@ config_roots = [
[tools]
node = "24.13.1"
flutter = "3.35.7"
flutter = "3.41.2"
pnpm = "10.29.3"
terragrunt = "0.98.0"
opentofu = "1.11.4"

View File

@@ -38,10 +38,10 @@ PODS:
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- MapLibre (6.14.0)
- maplibre_gl (0.0.1):
- MapLibre (6.23.0)
- maplibre_ios (0.0.1):
- Flutter
- MapLibre (= 6.14.0)
- MapLibre (~> 6.21)
- native_video_player (1.0.0):
- Flutter
- network_info_plus (0.0.1):
@@ -58,6 +58,8 @@ PODS:
- photo_manager (3.7.1):
- Flutter
- FlutterMacOS
- pointer_interceptor_ios (0.0.1):
- Flutter
- SAMKeychain (1.5.3)
- share_handler_ios (0.0.14):
- Flutter
@@ -75,16 +77,16 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.49.1):
- sqlite3/common (= 3.49.1)
- sqlite3/common (3.49.1)
- sqlite3/dbstatvtab (3.49.1):
- sqlite3 (3.49.2):
- sqlite3/common (= 3.49.2)
- sqlite3/common (3.49.2)
- sqlite3/dbstatvtab (3.49.2):
- sqlite3/common
- sqlite3/fts5 (3.49.1):
- sqlite3/fts5 (3.49.2):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/perf-threadsafe (3.49.2):
- sqlite3/common
- sqlite3/rtree (3.49.1):
- sqlite3/rtree (3.49.2):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
@@ -118,7 +120,7 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- maplibre_ios (from `.symlinks/plugins/maplibre_ios/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
@@ -126,6 +128,7 @@ DEPENDENCIES:
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -178,8 +181,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
maplibre_ios:
:path: ".symlinks/plugins/maplibre_ios/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
@@ -194,6 +197,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
@@ -230,8 +235,8 @@ SPEC CHECKSUMS:
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
MapLibre: c0fcafabb341f230657d959970c6eb47fb55750e
maplibre_ios: 05031d5f79702672d2c01cc77b6ba3187d4bf896
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
@@ -239,13 +244,14 @@ SPEC CHECKSUMS:
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556

View File

@@ -446,6 +446,7 @@
packageReferences = (
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
A1B2C3D4E5F6A7B8C9D0E1F2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@@ -1250,6 +1251,14 @@
minimumVersion = 1.5.0;
};
};
A1B2C3D4E5F6A7B8C9D0E1F2 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/maplibre/maplibre-gl-native-distribution";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 6.21.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */

View File

@@ -10,6 +10,15 @@
"version" : "1.0.3"
}
},
{
"identity" : "maplibre-gl-native-distribution",
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution",
"state" : {
"revision" : "2aefb4dd47ca6e897c93086f348a457839aac2fe",
"version" : "6.23.0"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",

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

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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -1,3 +1,4 @@
// ignore_for_file: experimental_member_use
import 'dart:async';
import 'package:drift/drift.dart';

View File

@@ -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;
}
}

View File

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

View File

@@ -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;

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)';

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,
),

View File

@@ -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,
),

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

View File

@@ -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)}"),
),
],
),

View File

@@ -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,
),

View File

@@ -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});

View File

@@ -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,
),

View File

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

View File

@@ -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],
},
};
}

View File

@@ -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,
),
),
);

View File

@@ -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,

View File

@@ -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 {

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';

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() {

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!);
}
}

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);

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,

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],
},
};

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!);
}
}

View File

@@ -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,

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;
}
}

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);

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);

View File

@@ -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,

View File

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

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!),
),
],
),
],
),
),
),
),

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
[tools]
flutter = "3.35.7"
flutter = "3.41.2"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

View File

@@ -229,10 +229,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
charcode:
dependency: transitive
description:
@@ -273,6 +273,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder:
dependency: transitive
description:
@@ -776,6 +784,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
geobase:
dependency: transitive
description:
name: geobase
sha256: "3a4e2eb17a7ab452dda78fb45ee498a3ab02469ded749a5f4a9abea21fc95919"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
geoclue:
dependency: transitive
description:
@@ -872,6 +888,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.8.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
hooks_riverpod:
dependency: "direct main"
description:
@@ -1125,6 +1149,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
local_auth:
dependency: "direct main"
description:
@@ -1173,54 +1205,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
maplibre_gl:
maplibre:
dependency: "direct main"
description:
name: maplibre_gl
sha256: "5c7b1008396b2a321bada7d986ed60f9423406fbc7bd16f7ce91b385dfa054cd"
name: maplibre
sha256: "03aad98086ef8e24caf9abcbbacf43f7ceb6267a6b914d907f57fb05ccb65e09"
url: "https://pub.dev"
source: hosted
version: "0.22.0"
maplibre_gl_platform_interface:
version: "0.3.4"
maplibre_android:
dependency: transitive
description:
name: maplibre_gl_platform_interface
sha256: "08ee0a2d0853ea945a0ab619d52c0c714f43144145cd67478fc6880b52f37509"
name: maplibre_android
sha256: be8a9c29b20c10f4b2207790e8ab35489955a29cb69df10e38bdf993b44f1547
url: "https://pub.dev"
source: hosted
version: "0.22.0"
maplibre_gl_web:
version: "0.3.4"
maplibre_ios:
dependency: transitive
description:
name: maplibre_gl_web
sha256: "2b13d4b1955a9a54e38a718f2324e56e4983c080fc6de316f6f4b5458baacb58"
name: maplibre_ios
sha256: "3e261d99697cc191e64ceb256acec5d96662d429059057bb6c1740dd11eaa7c3"
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.3.4"
maplibre_platform_interface:
dependency: transitive
description:
name: maplibre_platform_interface
sha256: "7d912d82d41a31daed4e91a243a1324f483f7ffbf3671c229b98328beab854b4"
url: "https://pub.dev"
source: hosted
version: "0.3.4"
maplibre_web:
dependency: transitive
description:
name: maplibre_web
sha256: "7e79427cdb0098c1e054fb90f5f35f4538c69e8d170c0e92f6abe7c61e106461"
url: "https://pub.dev"
source: hosted
version: "0.3.4"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mgrs_dart:
dependency: transitive
description:
name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mime:
dependency: transitive
description:
@@ -1237,6 +1293,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
native_video_player:
dependency: "direct main"
description:
@@ -1477,6 +1541,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointer_interceptor:
dependency: transitive
description:
name: pointer_interceptor
sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523"
url: "https://pub.dev"
source: hosted
version: "0.10.1+2"
pointer_interceptor_ios:
dependency: transitive
description:
name: pointer_interceptor_ios
sha256: "03c5fa5896080963ab4917eeffda8d28c90f22863a496fb5ba13bc10943e40e4"
url: "https://pub.dev"
source: hosted
version: "0.10.1+1"
pointer_interceptor_platform_interface:
dependency: transitive
description:
name: pointer_interceptor_platform_interface
sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506"
url: "https://pub.dev"
source: hosted
version: "0.10.0+1"
pointer_interceptor_web:
dependency: transitive
description:
name: pointer_interceptor_web
sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a"
url: "https://pub.dev"
source: hosted
version: "0.10.3"
pool:
dependency: transitive
description:
@@ -1501,6 +1597,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.3"
proj4dart:
dependency: transitive
description:
name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
protobuf:
dependency: transitive
description:
@@ -1910,10 +2014,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.9"
thumbhash:
dependency: "direct main"
description:
@@ -1954,6 +2058,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
unicode:
dependency: transitive
description:
name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
universal_io:
dependency: transitive
description:
@@ -2162,6 +2274,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.3"
wkt_parser:
dependency: transitive
description:
name: wkt_parser
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
worker_manager:
dependency: "direct main"
description:
@@ -2203,5 +2323,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.7"
dart: ">=3.11.0 <4.0.0"
flutter: "3.41.2"

View File

@@ -5,8 +5,8 @@ publish_to: 'none'
version: 2.5.6+3037
environment:
sdk: '>=3.8.0 <4.0.0'
flutter: 3.35.7
sdk: '>=3.11.0 <4.0.0'
flutter: 3.41.2
dependencies:
async: ^2.13.0
@@ -52,7 +52,7 @@ dependencies:
isar_community_flutter_libs: 3.3.0-dev.3
local_auth: ^2.3.0
logging: ^1.3.0
maplibre_gl: ^0.22.0
maplibre: ^0.3.4
native_video_player:
git:

View File

@@ -1,7 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:maplibre/maplibre.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -85,9 +85,9 @@ void main() {
expect(receivedDatetime.every((d) => d == dateTime), isTrue);
});
test("asset is updated with LatLng", () async {
test("asset is updated with Geographic", () async {
final assets = [AssetStub.image1, AssetStub.image2];
final latLng = const LatLng(37.7749, -122.4194);
final latLng = const Geographic(lat: 37.7749, lon: -122.4194);
await sut.changeLocation(assets, latLng);
verify(() => assetsApi.updateAssets(any())).called(1);
@@ -95,7 +95,7 @@ void main() {
upsertExifCallback.called(1);
final receivedAssets = upsertExifCallback.captured.firstOrNull as List<Object>? ?? [];
final receivedCoords = receivedAssets.cast<Asset>().map(
(a) => LatLng(a.exifInfo?.latitude ?? 0, a.exifInfo?.longitude ?? 0),
(a) => Geographic(lat: a.exifInfo?.latitude ?? 0, lon: a.exifInfo?.longitude ?? 0),
);
expect(receivedCoords.every((l) => l == latLng), isTrue);
});

View File

@@ -59,7 +59,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
# Flutter SDK
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
ENV FLUTTER_CHANNEL="stable"
ENV FLUTTER_VERSION="3.35.7"
ENV FLUTTER_VERSION="3.41.2"
ENV FLUTTER_HOME=/flutter
ENV PATH=${PATH}:${FLUTTER_HOME}/bin