diff --git a/mobile/lib/domain/models/map.model.dart b/mobile/lib/domain/models/map.model.dart new file mode 100644 index 0000000000..818e8d70f5 --- /dev/null +++ b/mobile/lib/domain/models/map.model.dart @@ -0,0 +1,21 @@ +import 'package:maplibre_gl/maplibre_gl.dart'; + +class Marker { + final LatLng location; + final String assetId; + + const Marker({ + required this.location, + required this.assetId, + }); + + @override + bool operator ==(covariant Marker other) { + if (identical(this, other)) return true; + + return other.location == location && other.assetId == assetId; + } + + @override + int get hashCode => location.hashCode ^ assetId.hashCode; +} diff --git a/mobile/lib/domain/services/map.service.dart b/mobile/lib/domain/services/map.service.dart new file mode 100644 index 0000000000..de9e9eb270 --- /dev/null +++ b/mobile/lib/domain/services/map.service.dart @@ -0,0 +1,36 @@ +import 'package:immich_mobile/domain/models/map.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +typedef MapMarkerSource = Stream> Function(LatLngBounds bounds); + +class MapFactory { + final DriftMapRepository _mapRepository; + + const MapFactory({ + required DriftMapRepository mapRepository, + }) : _mapRepository = mapRepository; + + MapService main(List timelineUsers) => MapService( + markerSource: (bounds) => + _mapRepository.watchMainMarker(timelineUsers, bounds: bounds), + ); + + MapService remoteAlbum({required String albumId}) => MapService( + markerSource: (bounds) => + _mapRepository.watchRemoteAlbumMarker(albumId, bounds: bounds), + ); +} + +class MapService { + final MapMarkerSource _markerSource; + + const MapService({ + required MapMarkerSource markerSource, + }) : _markerSource = markerSource; + + Stream> Function(LatLngBounds bounds) get watchMarkers => + _markerSource; + + Future dispose() async {} +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 9fa4106d17..3b580c01d0 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; typedef TimelineAssetSource = Future> Function(int index, int count); @@ -57,6 +58,9 @@ class TimelineFactory { TimelineService(_timelineRepository.person(userId, personId, groupBy)); TimelineService fromAssets(List assets) => TimelineService(_timelineRepository.fromAssets(assets)); + + TimelineService map(LatLngBounds bounds) => + TimelineService(_timelineRepository.map(bounds, groupBy)); } class TimelineService { diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart new file mode 100644 index 0000000000..aaf402a422 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -0,0 +1,101 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/map.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:stream_transform/stream_transform.dart'; + +class DriftMapRepository extends DriftDatabaseRepository { + final Drift _db; + + const DriftMapRepository(super._db) : _db = _db; + + Stream> watchMainMarker( + List userIds, { + required LatLngBounds bounds, + }) { + final query = _db.remoteExifEntity.select().join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId), + useColumns: false, + ), + ]) + ..where( + _db.remoteExifEntity.latitude.isNotNull() & + _db.remoteExifEntity.longitude.isNotNull() & + _db.remoteExifEntity.inBounds(bounds) & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.ownerId.isIn(userIds), + ); + + return query + .map((row) => row.readTable(_db.remoteExifEntity).toMarker()) + .watch() + .throttle(const Duration(seconds: 3)); + } + + Stream> watchRemoteAlbumMarker( + String albumId, { + required LatLngBounds bounds, + }) { + final query = _db.remoteExifEntity.select().join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.remoteExifEntity.latitude.isNotNull() & + _db.remoteExifEntity.longitude.isNotNull() & + _db.remoteExifEntity.inBounds(bounds) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAlbumAssetEntity.albumId.equals(albumId), + ); + + return query + .map((row) => row.readTable(_db.remoteExifEntity).toMarker()) + .watch() + .throttle(const Duration(seconds: 3)); + } +} + +extension MapBounds on $RemoteExifEntityTable { + Expression inBounds(LatLngBounds bounds) { + final isLatitudeInBounds = + latitude.isBiggerOrEqualValue(bounds.southwest.latitude) & + latitude.isSmallerOrEqualValue(bounds.northeast.latitude); + + final Expression isLongitudeInBounds; + + if (bounds.southwest.longitude <= bounds.northeast.longitude) { + isLongitudeInBounds = + longitude.isBiggerOrEqualValue(bounds.southwest.longitude) & + longitude.isSmallerOrEqualValue(bounds.northeast.longitude); + } else { + isLongitudeInBounds = + longitude.isBiggerOrEqualValue(bounds.southwest.longitude) | + longitude.isSmallerOrEqualValue(bounds.northeast.longitude); + } + + return isLatitudeInBounds & isLongitudeInBounds; + } +} + +extension on RemoteExifEntityData { + Marker toMarker() { + return Marker( + assetId: assetId, + location: LatLng(latitude!, longitude!), + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 663db9b82f..389ece5206 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -11,6 +11,8 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; 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:stream_transform/stream_transform.dart'; class DriftTimelineRepository extends DriftDatabaseRepository { @@ -427,6 +429,88 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } + TimelineQuery map(LatLngBounds bounds, GroupAssetsBy groupBy) => ( + bucketSource: () => _watchMapBucket( + bounds, + groupBy: groupBy, + ), + assetSource: (offset, count) => _getMapBucketAssets( + bounds, + offset: offset, + count: count, + ), + ); + + Stream> _watchMapBucket( + LatLngBounds bounds, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + // TODO: Support GroupAssetsBy.none + throw UnsupportedError( + "GroupAssetsBy.none is not supported for _watchMapBucket", + ); + } + + final assetCountExp = _db.remoteAssetEntity.id.count(); + final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.remoteExifEntity, + _db.remoteExifEntity.assetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.remoteExifEntity.latitude.isNotNull() & + _db.remoteExifEntity.longitude.isNotNull() & + _db.remoteExifEntity.inBounds(bounds) & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.deletedAt.isNull(), + ) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + Future> _getMapBucketAssets( + LatLngBounds bounds, { + required int offset, + required int count, + }) { + final query = _db.remoteAssetEntity.select().join( + [ + innerJoin( + _db.remoteExifEntity, + _db.remoteExifEntity.assetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ], + ) + ..where( + _db.remoteExifEntity.latitude.isNotNull() & + _db.remoteExifEntity.longitude.isNotNull() & + _db.remoteExifEntity.inBounds(bounds) & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.deletedAt.isNull(), + ) + ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) + ..limit(count, offset: offset); + return query + .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) + .get(); + } + TimelineQuery _remoteQueryBuilder({ required Expression Function($RemoteAssetEntityTable row) filter, GroupAssetsBy groupBy = GroupAssetsBy.day, diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index 2ab1eeaaa9..1ca46574c7 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -22,6 +22,11 @@ final _features = [ icon: Icons.timeline_rounded, onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()), ), + _Feature( + name: 'Map', + icon: Icons.map_outlined, + onTap: (ctx, _) => ctx.pushRoute(const DriftMapRoute()), + ), _Feature( name: 'Selection Mode Timeline', icon: Icons.developer_mode_rounded, diff --git a/mobile/lib/presentation/pages/drift_map.page.dart b/mobile/lib/presentation/pages/drift_map.page.dart new file mode 100644 index 0000000000..0555f42452 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_map.page.dart @@ -0,0 +1,16 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.widget.dart'; + +@RoutePage() +class DriftMapPage extends StatelessWidget { + const DriftMapPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + extendBodyBehindAppBar: true, + body: DriftMapWithMarker(), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart new file mode 100644 index 0000000000..542ededbbf --- /dev/null +++ b/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class MapBottomSheet extends ConsumerWidget { + const MapBottomSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + LatLngBounds bounds = ref.watch(mapStateProvider.select((s) => s.bounds)); + + ref.listen(mapStateProvider, (previous, next) async { + bounds = next.bounds; + }); + + return ProviderScope( + overrides: [ + // TODO: refactor (timeline): when ProviderScope changed, we should refresh timeline + timelineServiceProvider.overrideWith((ref) { + final timelineService = + ref.watch(timelineFactoryProvider).map(bounds); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: const BaseBottomSheet( + initialChildSize: 0.25, + shouldCloseOnMinExtent: false, + actions: [], + slivers: [ + SliverFillRemaining( + child: Timeline(), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/map/map.state.dart b/mobile/lib/presentation/widgets/map/map.state.dart new file mode 100644 index 0000000000..e68d80390e --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -0,0 +1,58 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/map/marker_build.dart'; +import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class MapState { + final LatLngBounds bounds; + + const MapState({required this.bounds}); + + @override + bool operator ==(covariant MapState other) { + return bounds == other.bounds; + } + + @override + int get hashCode => bounds.hashCode; + + MapState copyWith({LatLngBounds? bounds}) { + return MapState(bounds: bounds ?? this.bounds); + } +} + +class MapStateNotifier extends Notifier { + MapStateNotifier(); + + void setBounds(LatLngBounds bounds) { + state = state.copyWith(bounds: bounds); + } + + @override + MapState build() => MapState( + // TODO: set default bounds + bounds: LatLngBounds( + northeast: const LatLng(0, 0), + southwest: const LatLng(0, 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 = + StreamProvider.family, LatLngBounds>( + (ref, bounds) async* { + final mapService = ref.watch(mapServiceProvider); + yield* mapService.watchMarkers(bounds).map((markers) { + return MarkerBuilder( + markers: markers, + ).generate(); + }); + }, + dependencies: [mapServiceProvider], +); + +final mapStateProvider = NotifierProvider( + MapStateNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/map/map.widget.dart b/mobile/lib/presentation/widgets/map/map.widget.dart new file mode 100644 index 0000000000..ed99a1f4c0 --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -0,0 +1,207 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:geolocator/geolocator.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/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.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 DriftMapWithMarker extends ConsumerStatefulWidget { + const DriftMapWithMarker({super.key}); + + @override + ConsumerState createState() => _DriftMapWithMarkerState(); +} + +class _DriftMapWithMarkerState extends ConsumerState { + MapLibreMapController? mapController; + static const mapZoomToAssetLevel = 12.0; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + Future onMapCreated(MapLibreMapController controller) async { + mapController = controller; + await setBounds(); + } + + Future onMapMoved() async { + await setBounds(); + } + + Future setBounds() async { + if (mapController == null) return; + final bounds = await mapController!.getVisibleRegion(); + ref.read(mapStateProvider.notifier).setBounds(bounds); + } + + Future reloadMarkers(Map markers) async { + if (mapController == null) return; + + // Wait for previous reload to complete + if (!MapUtils.completer.isCompleted) { + return MapUtils.completer.future; + } + MapUtils.completer = Completer(); + + // !! 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 mapController!.getLayerIds(); + if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) { + await mapController!.removeLayer(MapUtils.defaultHeatMapLayerId); + } + + final existingSources = await mapController!.getSourceIds(); + if (existingSources.contains(MapUtils.defaultSourceId)) { + await mapController!.removeSource(MapUtils.defaultSourceId); + } + + await mapController!.addSource( + MapUtils.defaultSourceId, GeojsonSourceProperties(data: markers), + ); + + if (Platform.isAndroid) { + await mapController!.addCircleLayer( + MapUtils.defaultSourceId, + MapUtils.defaultHeatMapLayerId, + MapUtils.defaultCircleLayerLayerProperties, + ); + } else if (Platform.isIOS) { + await mapController!.addHeatmapLayer( + MapUtils.defaultSourceId, + MapUtils.defaultHeatMapLayerId, + MapUtils.defaultHeatmapLayerProperties, + ); + } + + MapUtils.completer.complete(); + } + + Future onZoomToLocation() async { + final (location, error) = + await MapUtils.checkPermAndGetLocation(context: context); + if (error != null) { + if (error == LocationPermission.unableToDetermine && context.mounted) { + ImmichToast.show( + context: context, + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + msg: "map_cannot_get_user_location".t(context: context), + ); + } + return; + } + + if (mapController != null && location != null) { + mapController!.animateCamera( + CameraUpdate.newLatLngZoom( + LatLng(location.latitude, location.longitude), + mapZoomToAssetLevel, + ), + duration: const Duration(milliseconds: 800), + ); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + _Map( + onMapCreated: onMapCreated, + onMapMoved: onMapMoved, + ), + _MyLocationButton(onZoomToLocation: onZoomToLocation), + _Markers( + reloadMarkers: reloadMarkers, + ), + ], + ); + } +} + +class _Map extends StatelessWidget { + const _Map({ + required this.onMapCreated, + required this.onMapMoved, + }); + + final MapCreatedCallback onMapCreated; + final OnCameraIdleCallback onMapMoved; + + @override + Widget build(BuildContext context) { + return MapThemeOverride( + mapBuilder: (style) => style.widgetWhen( + onData: (style) => MapLibreMap( + initialCameraPosition: const CameraPosition( + target: LatLng(0, 0), + zoom: 0, + ), + styleString: style, + onMapCreated: onMapCreated, + onCameraIdle: onMapMoved, + ), + ), + ); + } +} + +class _Markers extends ConsumerWidget { + const _Markers({required this.reloadMarkers}); + + final Function(Map) reloadMarkers; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bounds = ref.watch(mapStateProvider.select((s) => s.bounds)); + AsyncValue> markers = + ref.watch(mapMarkerProvider(bounds)); + + ref.listen(mapStateProvider, (previous, next) async { + markers = ref.watch(mapMarkerProvider(next.bounds)); + }); + + markers.whenData((markers) => reloadMarkers(markers)); + + return const MapBottomSheet(); + } +} + +class _MyLocationButton extends StatelessWidget { + const _MyLocationButton({required this.onZoomToLocation}); + + final VoidCallback onZoomToLocation; + + @override + Widget build(BuildContext context) { + return Positioned( + right: 0, + bottom: context.padding.bottom + 16, + child: ElevatedButton( + onPressed: onZoomToLocation, + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + ), + child: const Icon(Icons.my_location), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/map/map_utils.dart b/mobile/lib/presentation/widgets/map/map_utils.dart new file mode 100644 index 0000000000..b9b3945616 --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map_utils.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +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"); + + static const defaultSourceId = 'asset-map-markers'; + static const defaultHeatMapLayerId = 'asset-heatmap-layer'; + static var completer = 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"], + 0.0, + "rgba(103,58,183,0.0)", + 0.3, + "rgb(103,58,183)", + 0.5, + "rgb(33,149,243)", + 0.7, + "rgb(76,175,79)", + 0.95, + "rgb(255,235,59)", + 1.0, + "rgb(255,86,34)", + ], + heatmapIntensity: [ + Expressions.interpolate, + ["linear"], + [Expressions.zoom], + 0, + 0.5, + 9, + 2, + ], + heatmapRadius: [ + Expressions.interpolate, + ["linear"], + [Expressions.zoom], + 0, + 4, + 4, + 8, + 9, + 16, + ], + heatmapOpacity: 0.7, + ); + + static Future<(Position?, LocationPermission?)> checkPermAndGetLocation({ + required BuildContext context, + bool silent = false, + }) async { + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled && !silent) { + showDialog( + context: context, + builder: (context) => _LocationServiceDisabledDialog(context), + ); + return (null, LocationPermission.deniedForever); + } + + LocationPermission permission = await Geolocator.checkPermission(); + bool shouldRequestPermission = false; + + if (permission == LocationPermission.denied && !silent) { + shouldRequestPermission = await showDialog( + context: context, + builder: (context) => _LocationPermissionDisabledDialog(context), + ); + if (shouldRequestPermission) { + permission = await Geolocator.requestPermission(); + } + } + + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + // Open app settings only if you did not request for permission before + if (permission == LocationPermission.deniedForever && + !shouldRequestPermission && + !silent) { + await Geolocator.openAppSettings(); + } + return (null, LocationPermission.deniedForever); + } + + Position currentUserLocation = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 0, + timeLimit: Duration(seconds: 5), + ), + ); + return (currentUserLocation, null); + } catch (error, stack) { + _logger.severe("Cannot get user's current location", error, stack); + return (null, LocationPermission.unableToDetermine); + } + } +} + +class _LocationServiceDisabledDialog extends ConfirmDialog { + _LocationServiceDisabledDialog(BuildContext context) + : super( + title: 'map_location_service_disabled_title'.t(context: context), + content: 'map_location_service_disabled_content'.t(context: context), + cancel: 'cancel'.t(context: context), + ok: 'yes'.t(context: context), + onOk: () async { + await Geolocator.openLocationSettings(); + }, + ); +} + +class _LocationPermissionDisabledDialog extends ConfirmDialog { + _LocationPermissionDisabledDialog(BuildContext context) + : super( + title: 'map_no_location_permission_title'.t(context: context), + content: 'map_no_location_permission_content'.t(context: context), + cancel: 'cancel'.t(context: context), + ok: 'yes'.t(context: context), + onOk: () {}, + ); +} diff --git a/mobile/lib/presentation/widgets/map/marker_build.dart b/mobile/lib/presentation/widgets/map/marker_build.dart new file mode 100644 index 0000000000..c2f4f41e36 --- /dev/null +++ b/mobile/lib/presentation/widgets/map/marker_build.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/domain/models/map.model.dart'; + +class MarkerBuilder { + final List markers; + + const MarkerBuilder({required this.markers}); + + static Map addFeature(Marker marker) => { + 'type': 'Feature', + 'id': marker.assetId, + 'geometry': { + 'type': 'Point', + 'coordinates': [marker.location.longitude, marker.location.latitude], + }, + }; + + Map generate() => { + 'type': 'FeatureCollection', + 'features': markers.map(addFeature).toList(), + }; +} diff --git a/mobile/lib/providers/infrastructure/map.provider.dart b/mobile/lib/providers/infrastructure/map.provider.dart new file mode 100644 index 0000000000..fa07c0f099 --- /dev/null +++ b/mobile/lib/providers/infrastructure/map.provider.dart @@ -0,0 +1,27 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/domain/services/map.service.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +final mapRepositoryProvider = Provider( + (ref) => DriftMapRepository(ref.watch(driftProvider)), +); + +final mapServiceProvider = Provider( + (ref) { + final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? []; + final mapService = ref.watch(mapFactoryProvider).main(timelineUsers); + ref.onDispose(mapService.dispose); + return mapService; + }, + // Empty dependencies to inform the framework that this provider + // might be used in a ProviderScope + dependencies: [], +); + +final mapFactoryProvider = Provider( + (ref) => MapFactory( + mapRepository: ref.watch(mapRepositoryProvider), + ), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 4fe1673893..2b799f62a8 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -88,6 +88,7 @@ import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_map.page.dart'; import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart'; @@ -329,6 +330,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftMapRoute.page,guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e8f0dd8b1f..a911d52646 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -876,6 +876,22 @@ class DriftLockedFolderRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftMapPage] +class DriftMapRoute extends PageRouteInfo { + const DriftMapRoute({List? children}) + : super(DriftMapRoute.name, initialChildren: children); + + static const String name = 'DriftMapRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftMapPage(); + }, + ); +} + /// generated route for /// [DriftMemoryPage] class DriftMemoryRoute extends PageRouteInfo {