mirror of
https://github.com/immich-app/immich.git
synced 2025-08-11 09:16:31 -04:00
feat(mobile): drift map page
This commit is contained in:
parent
3e92e837f1
commit
48f0e2d898
21
mobile/lib/domain/models/map.model.dart
Normal file
21
mobile/lib/domain/models/map.model.dart
Normal file
@ -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;
|
||||||
|
}
|
36
mobile/lib/domain/services/map.service.dart
Normal file
36
mobile/lib/domain/services/map.service.dart
Normal file
@ -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<List<Marker>> Function(LatLngBounds bounds);
|
||||||
|
|
||||||
|
class MapFactory {
|
||||||
|
final DriftMapRepository _mapRepository;
|
||||||
|
|
||||||
|
const MapFactory({
|
||||||
|
required DriftMapRepository mapRepository,
|
||||||
|
}) : _mapRepository = mapRepository;
|
||||||
|
|
||||||
|
MapService main(List<String> 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<List<Marker>> Function(LatLngBounds bounds) get watchMarkers =>
|
||||||
|
_markerSource;
|
||||||
|
|
||||||
|
Future<void> dispose() async {}
|
||||||
|
}
|
@ -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/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||||
|
|
||||||
@ -57,6 +58,9 @@ class TimelineFactory {
|
|||||||
TimelineService(_timelineRepository.person(userId, personId, groupBy));
|
TimelineService(_timelineRepository.person(userId, personId, groupBy));
|
||||||
|
|
||||||
TimelineService fromAssets(List<BaseAsset> assets) => TimelineService(_timelineRepository.fromAssets(assets));
|
TimelineService fromAssets(List<BaseAsset> assets) => TimelineService(_timelineRepository.fromAssets(assets));
|
||||||
|
|
||||||
|
TimelineService map(LatLngBounds bounds) =>
|
||||||
|
TimelineService(_timelineRepository.map(bounds, groupBy));
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineService {
|
class TimelineService {
|
||||||
|
101
mobile/lib/infrastructure/repositories/map.repository.dart
Normal file
101
mobile/lib/infrastructure/repositories/map.repository.dart
Normal file
@ -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<List<Marker>> watchMainMarker(
|
||||||
|
List<String> 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<List<Marker>> 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<bool> inBounds(LatLngBounds bounds) {
|
||||||
|
final isLatitudeInBounds =
|
||||||
|
latitude.isBiggerOrEqualValue(bounds.southwest.latitude) &
|
||||||
|
latitude.isSmallerOrEqualValue(bounds.northeast.latitude);
|
||||||
|
|
||||||
|
final Expression<bool> 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!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.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/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';
|
import 'package:stream_transform/stream_transform.dart';
|
||||||
|
|
||||||
class DriftTimelineRepository extends DriftDatabaseRepository {
|
class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||||
@ -427,6 +429,88 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
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<List<Bucket>> _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<List<BaseAsset>> _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({
|
TimelineQuery _remoteQueryBuilder({
|
||||||
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
||||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
|
@ -22,6 +22,11 @@ final _features = [
|
|||||||
icon: Icons.timeline_rounded,
|
icon: Icons.timeline_rounded,
|
||||||
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
|
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
|
||||||
),
|
),
|
||||||
|
_Feature(
|
||||||
|
name: 'Map',
|
||||||
|
icon: Icons.map_outlined,
|
||||||
|
onTap: (ctx, _) => ctx.pushRoute(const DriftMapRoute()),
|
||||||
|
),
|
||||||
_Feature(
|
_Feature(
|
||||||
name: 'Selection Mode Timeline',
|
name: 'Selection Mode Timeline',
|
||||||
icon: Icons.developer_mode_rounded,
|
icon: Icons.developer_mode_rounded,
|
||||||
|
16
mobile/lib/presentation/pages/drift_map.page.dart
Normal file
16
mobile/lib/presentation/pages/drift_map.page.dart
Normal file
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
58
mobile/lib/presentation/widgets/map/map.state.dart
Normal file
58
mobile/lib/presentation/widgets/map/map.state.dart
Normal file
@ -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<MapState> {
|
||||||
|
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<Map<String, dynamic>, 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, MapState>(
|
||||||
|
MapStateNotifier.new,
|
||||||
|
);
|
207
mobile/lib/presentation/widgets/map/map.widget.dart
Normal file
207
mobile/lib/presentation/widgets/map/map.widget.dart
Normal file
@ -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<DriftMapWithMarker> createState() => _DriftMapWithMarkerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DriftMapWithMarkerState extends ConsumerState<DriftMapWithMarker> {
|
||||||
|
MapLibreMapController? mapController;
|
||||||
|
static const mapZoomToAssetLevel = 12.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onMapCreated(MapLibreMapController controller) async {
|
||||||
|
mapController = controller;
|
||||||
|
await setBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onMapMoved() async {
|
||||||
|
await setBounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setBounds() async {
|
||||||
|
if (mapController == null) return;
|
||||||
|
final bounds = await mapController!.getVisibleRegion();
|
||||||
|
ref.read(mapStateProvider.notifier).setBounds(bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reloadMarkers(Map<String, dynamic> 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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, dynamic>) reloadMarkers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final bounds = ref.watch(mapStateProvider.select((s) => s.bounds));
|
||||||
|
AsyncValue<Map<String, dynamic>> 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
143
mobile/lib/presentation/widgets/map/map_utils.dart
Normal file
143
mobile/lib/presentation/widgets/map/map_utils.dart
Normal file
@ -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: () {},
|
||||||
|
);
|
||||||
|
}
|
21
mobile/lib/presentation/widgets/map/marker_build.dart
Normal file
21
mobile/lib/presentation/widgets/map/marker_build.dart
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/map.model.dart';
|
||||||
|
|
||||||
|
class MarkerBuilder {
|
||||||
|
final List<Marker> markers;
|
||||||
|
|
||||||
|
const MarkerBuilder({required this.markers});
|
||||||
|
|
||||||
|
static Map<String, dynamic> addFeature(Marker marker) => {
|
||||||
|
'type': 'Feature',
|
||||||
|
'id': marker.assetId,
|
||||||
|
'geometry': {
|
||||||
|
'type': 'Point',
|
||||||
|
'coordinates': [marker.location.longitude, marker.location.latitude],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> generate() => {
|
||||||
|
'type': 'FeatureCollection',
|
||||||
|
'features': markers.map(addFeature).toList(),
|
||||||
|
};
|
||||||
|
}
|
27
mobile/lib/providers/infrastructure/map.provider.dart
Normal file
27
mobile/lib/providers/infrastructure/map.provider.dart
Normal file
@ -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<DriftMapRepository>(
|
||||||
|
(ref) => DriftMapRepository(ref.watch(driftProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final mapServiceProvider = Provider<MapService>(
|
||||||
|
(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<MapFactory>(
|
||||||
|
(ref) => MapFactory(
|
||||||
|
mapRepository: ref.watch(mapRepositoryProvider),
|
||||||
|
),
|
||||||
|
);
|
@ -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_library.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_local_album.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_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_memory.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_people_collection.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: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
|
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
|
||||||
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: DriftMapRoute.page,guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
@ -876,6 +876,22 @@ class DriftLockedFolderRoute extends PageRouteInfo<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftMapPage]
|
||||||
|
class DriftMapRoute extends PageRouteInfo<void> {
|
||||||
|
const DriftMapRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(DriftMapRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'DriftMapRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const DriftMapPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftMemoryPage]
|
/// [DriftMemoryPage]
|
||||||
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
|
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user