mirror of https://github.com/immich-app/immich.git
refactor(mobile): sqlite-based map view (#20665)
* feat(mobile): drift map page * refactor: map query * perf: do not filter markers * fix: refresh timeline by key * chore: rename * remove ref listen and global key * clean code * remove locked and favorite * temporary change for stress test * optimizations * fix bottom sheet * cleaner bounds check * cleanup * feat: back button --------- Co-authored-by: wuzihao051119 <wuzihao051119@outlook.com> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/20414/head
parent
1ca46fbd98
commit
0121043d7d
File diff suppressed because one or more lines are too long
@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
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 = Future<List<Marker>> Function(LatLngBounds? bounds);
|
||||||
|
|
||||||
|
typedef MapQuery = ({MapMarkerSource markerSource});
|
||||||
|
|
||||||
|
class MapFactory {
|
||||||
|
final DriftMapRepository _mapRepository;
|
||||||
|
|
||||||
|
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||||
|
|
||||||
|
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapService {
|
||||||
|
final MapMarkerSource _markerSource;
|
||||||
|
|
||||||
|
MapService(MapQuery query) : _markerSource = query.markerSource;
|
||||||
|
|
||||||
|
Future<List<Marker>> Function(LatLngBounds? bounds) get getMarkers => _markerSource;
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
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/domain/services/map.service.dart';
|
||||||
|
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:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
|
class DriftMapRepository extends DriftDatabaseRepository {
|
||||||
|
final Drift _db;
|
||||||
|
|
||||||
|
const DriftMapRepository(super._db) : _db = _db;
|
||||||
|
|
||||||
|
MapQuery remote(String ownerId) => _mapQueryBuilder(
|
||||||
|
assetFilter: (row) =>
|
||||||
|
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId),
|
||||||
|
);
|
||||||
|
|
||||||
|
MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) {
|
||||||
|
return (markerSource: (bounds) => _watchMapMarker(assetFilter: assetFilter, bounds: bounds));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Marker>> _watchMapMarker({
|
||||||
|
Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter,
|
||||||
|
LatLngBounds? bounds,
|
||||||
|
}) async {
|
||||||
|
final assetId = _db.remoteExifEntity.assetId;
|
||||||
|
final latitude = _db.remoteExifEntity.latitude;
|
||||||
|
final longitude = _db.remoteExifEntity.longitude;
|
||||||
|
|
||||||
|
final query = _db.remoteExifEntity.selectOnly()
|
||||||
|
..addColumns([assetId, latitude, longitude])
|
||||||
|
..join([innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(assetId), useColumns: false)])
|
||||||
|
..limit(10000);
|
||||||
|
|
||||||
|
if (assetFilter != null) {
|
||||||
|
query.where(assetFilter(_db.remoteAssetEntity));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bounds != null) {
|
||||||
|
query.where(_db.remoteExifEntity.inBounds(bounds));
|
||||||
|
} else {
|
||||||
|
query.where(latitude.isNotNull() & longitude.isNotNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
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)!));
|
||||||
|
}, 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));
|
||||||
|
return latInBounds & longInBounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
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:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DriftMapPage extends StatelessWidget {
|
||||||
|
final LatLng? initialLocation;
|
||||||
|
|
||||||
|
const DriftMapPage({super.key, this.initialLocation});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
extendBodyBehindAppBar: true,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
DriftMap(initialLocation: initialLocation),
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: 60,
|
||||||
|
child: IconButton.filled(
|
||||||
|
color: Colors.white,
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
shape: const CircleBorder(side: BorderSide(width: 1, color: Colors.black26)),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
backgroundColor: Colors.indigo.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
|
class MapBottomSheet extends StatelessWidget {
|
||||||
|
const MapBottomSheet({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const BaseBottomSheet(
|
||||||
|
initialChildSize: 0.25,
|
||||||
|
maxChildSize: 0.9,
|
||||||
|
shouldCloseOnMinExtent: false,
|
||||||
|
resizeOnScroll: false,
|
||||||
|
actions: [],
|
||||||
|
slivers: [SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScopedMapTimeline extends StatelessWidget {
|
||||||
|
const _ScopedMapTimeline();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
timelineServiceProvider.overrideWith((ref) {
|
||||||
|
final bounds = ref.watch(mapStateProvider).bounds;
|
||||||
|
final timelineService = ref.watch(timelineFactoryProvider).map(bounds);
|
||||||
|
ref.onDispose(timelineService.dispose);
|
||||||
|
return timelineService;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
child: const Timeline(appBar: null, bottomSheet: null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.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<MapState> {
|
||||||
|
MapStateNotifier();
|
||||||
|
|
||||||
|
bool setBounds(LatLngBounds bounds) {
|
||||||
|
if (state.bounds == bounds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state = state.copyWith(bounds: bounds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = FutureProvider.family<Map<String, dynamic>, LatLngBounds?>((ref, bounds) async {
|
||||||
|
final mapService = ref.watch(mapServiceProvider);
|
||||||
|
final markers = await mapService.getMarkers(bounds);
|
||||||
|
final features = List.filled(markers.length, const <String, dynamic>{});
|
||||||
|
for (int i = 0; i < markers.length; i++) {
|
||||||
|
final marker = markers[i];
|
||||||
|
features[i] = {
|
||||||
|
'type': 'Feature',
|
||||||
|
'id': marker.assetId,
|
||||||
|
'geometry': {
|
||||||
|
'type': 'Point',
|
||||||
|
'coordinates': [marker.location.longitude, marker.location.latitude],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {'type': 'FeatureCollection', 'features': features};
|
||||||
|
}, dependencies: [mapServiceProvider]);
|
||||||
|
|
||||||
|
final mapStateProvider = NotifierProvider<MapStateNotifier, MapState>(MapStateNotifier.new);
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
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/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DriftMap extends ConsumerStatefulWidget {
|
||||||
|
final LatLng? initialLocation;
|
||||||
|
|
||||||
|
const DriftMap({super.key, this.initialLocation});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DriftMap> createState() => _DriftMapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriftMapState extends ConsumerState<DriftMap> {
|
||||||
|
MapLibreMapController? mapController;
|
||||||
|
final _reloadMutex = AsyncMutex();
|
||||||
|
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debouncer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onMapCreated(MapLibreMapController controller) {
|
||||||
|
mapController = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onMapReady() async {
|
||||||
|
final controller = mapController;
|
||||||
|
if (controller == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await controller.addSource(
|
||||||
|
MapUtils.defaultSourceId,
|
||||||
|
const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.addHeatmapLayer(
|
||||||
|
MapUtils.defaultSourceId,
|
||||||
|
MapUtils.defaultHeatMapLayerId,
|
||||||
|
MapUtils.defaultHeatmapLayerProperties,
|
||||||
|
);
|
||||||
|
_debouncer.run(setBounds);
|
||||||
|
controller.addListener(onMapMoved);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onMapMoved() {
|
||||||
|
if (mapController!.isCameraMoving || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debouncer.run(setBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBounds() async {
|
||||||
|
final controller = mapController;
|
||||||
|
if (controller == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bounds = await controller.getVisibleRegion();
|
||||||
|
_reloadMutex.run(() async {
|
||||||
|
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
|
||||||
|
final markers = await ref.read(mapMarkerProvider(bounds).future);
|
||||||
|
await reloadMarkers(markers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reloadMarkers(Map<String, dynamic> markers) async {
|
||||||
|
final controller = mapController;
|
||||||
|
if (controller == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = mapController;
|
||||||
|
if (controller != null && location != null) {
|
||||||
|
controller.animateCamera(
|
||||||
|
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel),
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
|
||||||
|
_MyLocationButton(onZoomToLocation: onZoomToLocation),
|
||||||
|
const MapBottomSheet(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Map extends StatelessWidget {
|
||||||
|
final LatLng? initialLocation;
|
||||||
|
|
||||||
|
const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady});
|
||||||
|
|
||||||
|
final MapCreatedCallback onMapCreated;
|
||||||
|
|
||||||
|
final VoidCallback onMapReady;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final initialLocation = this.initialLocation;
|
||||||
|
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),
|
||||||
|
styleString: style,
|
||||||
|
onMapCreated: onMapCreated,
|
||||||
|
onStyleLoadedCallback: onMapReady,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
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 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"],
|
||||||
|
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: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
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/user.provider.dart';
|
||||||
|
|
||||||
|
final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider)));
|
||||||
|
|
||||||
|
final mapServiceProvider = Provider<MapService>(
|
||||||
|
(ref) {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
if (user == null) {
|
||||||
|
throw Exception('User must be logged in to access map');
|
||||||
|
}
|
||||||
|
|
||||||
|
final mapService = ref.watch(mapFactoryProvider).remote(user.id);
|
||||||
|
return mapService;
|
||||||
|
},
|
||||||
|
// Empty dependencies to inform the framework that this provider
|
||||||
|
// might be used in a ProviderScope
|
||||||
|
dependencies: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final mapFactoryProvider = Provider<MapFactory>((ref) => MapFactory(mapRepository: ref.watch(mapRepositoryProvider)));
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue