From 0121043d7d7cb8acf0972a645083d2458b64f0b3 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:05:49 -0400 Subject: [PATCH] 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 Co-authored-by: Alex --- .../drift_schemas/main/drift_schema_v7.json | Bin 0 -> 33538 bytes mobile/lib/domain/models/map.model.dart | 18 ++ mobile/lib/domain/services/map.service.dart | 23 +++ .../lib/domain/services/timeline.service.dart | 3 + .../infrastructure/entities/exif.entity.dart | 1 + .../entities/exif.entity.drift.dart | Bin 64470 -> 64600 bytes .../repositories/db.repository.dart | 7 +- .../repositories/db.repository.drift.dart | Bin 11933 -> 11951 bytes .../repositories/db.repository.steps.dart | Bin 82691 -> 92113 bytes .../repositories/map.repository.dart | 66 ++++++ .../repositories/timeline.repository.dart | 59 ++++++ .../presentation/pages/drift_map.page.dart | 38 ++++ .../presentation/pages/drift_place.page.dart | 3 +- .../base_bottom_sheet.widget.dart | 57 ++++-- .../bottom_sheet/map_bottom_sheet.widget.dart | 42 ++++ .../presentation/widgets/map/map.state.dart | 61 ++++++ .../presentation/widgets/map/map.widget.dart | 191 ++++++++++++++++++ .../presentation/widgets/map/map_utils.dart | 138 +++++++++++++ .../widgets/timeline/timeline.widget.dart | 12 +- .../infrastructure/map.provider.dart | 24 +++ mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 39 ++++ mobile/lib/utils/async_mutex.dart | 2 +- mobile/lib/utils/debounce.dart | 5 +- mobile/test/drift/main/generated/schema.dart | Bin 970 -> 1054 bytes .../test/drift/main/generated/schema_v7.dart | Bin 0 -> 208747 bytes 26 files changed, 758 insertions(+), 33 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v7.json create mode 100644 mobile/lib/domain/models/map.model.dart create mode 100644 mobile/lib/domain/services/map.service.dart create mode 100644 mobile/lib/infrastructure/repositories/map.repository.dart create mode 100644 mobile/lib/presentation/pages/drift_map.page.dart create mode 100644 mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart create mode 100644 mobile/lib/presentation/widgets/map/map.state.dart create mode 100644 mobile/lib/presentation/widgets/map/map.widget.dart create mode 100644 mobile/lib/presentation/widgets/map/map_utils.dart create mode 100644 mobile/lib/providers/infrastructure/map.provider.dart create mode 100644 mobile/test/drift/main/generated/schema_v7.dart diff --git a/mobile/drift_schemas/main/drift_schema_v7.json b/mobile/drift_schemas/main/drift_schema_v7.json new file mode 100644 index 0000000000000000000000000000000000000000..77f57c34d9d518d2bd9232ad6672e12a23ecd71c GIT binary patch literal 33538 zcmeGlYj4{)@~;T`vMFE`aofAy%`JLx6BjkMO}lln?cSykWJ`{DktMGld2EsYenX0s zh7xT{vLwqn1O3p<(ByD9oEL}GcimXnp{bvI*DX7k`eYdr&(%-#i#Z9j8F6fF>bapw z+(0w6!1jshkiTt9yS4oQT58@*3#M~>VQRJ;l91T&E6>*~pUlGNdQa!=pvV667tfFN z$DZ!7F#gs+V`P;68e!1zYtyPCx3p|iKU+$_jKQ$ z*}m;gZRqIFZ#_MHSlTd3Xigj(#)UrFtXy+J!4Z&dux=gzc?Sbn_S7+b+cJo2EIiNG zPiCeQ*gbvfInlzUy?)pEuVKiZer<;#bb>+jcLpCg2z}ySLpwKe97aUaVFc4$oO&)a z0Y-+u}7L6uh&LN8rOFv{)SgWZ ztQKgcTjGb2=_K~&j)ZeB3Ju@8qo$6UKx&kb^`PdCZtf|!kYFJ1mkybd&{*Dp-J&M) z+aom(w1a-eRvyEz*BeXUUU*=U%^PB-fY!YKcRx@!R-XmbPx7Z$~O?$jT2CO>x zPvCVh{|vkY0~Q+o;M;BJMvIYa+4o?u;b=$c)CEA;v(`S0`Ct70aH_Ccp7+?b@1 z&g6wVH&Y+n4E0Xqs6E?hsi*Yhlt?-?P#57mr~CzUoiuYD)4ZQLx5@Q9Z=e!zCy2Bn z-=t9xjSK=C_MjEyxGLI=+;P&P6U28uRDob|dJ{y897_=bb8@F%ESTq+dF%Pq|Ib

69V}cnzobk2NDX)$6opBzb6yq9(xMd z=(nd$CGR~5&FPJ5of)E2%j=MdZ~6}^VmYb<`m=M>SIqBBQF3TxbmjOv)a46WpL5`y`4$DAR zUzKyqRyy?M3ryPa*Irfh*PKbTsD2WyaF-bfFN9(1NGwkOU(y7_$}w-TIMKztBt?%}+l@dV61l&MPNLEN=umSM3oGs=O%g1L) z-v2REZ0(F}X#cC+$;Oe$3F)Ur!5^a3Wfy z^2|8ho(utnp+!hSN&4icDRngO!97QtTFp zRV%dP`sK@@S)mZU9Yxhb_2`pzqXb^m<$k|8%#hjWr-UJ%bebvg7x8%vlAt%a<749Vl|iL!hqM!tDC zXqb%`DV6R&XD+>rqY46I^cTP$7+j6PP+U*1{5r%{lvca+J{m_!daC9-p(zb@q%_c! zN4sjvmub`%NAiEKX1itZ0@<+_heRG{h?@2HgK&aL6iYVy%psphR6w7 z+2om055lFwysO25cxNikPl_cP$6JaS#|TX9&V+TwZbUh@d#$(=%aHE{4%53!<~mS35=K^@w+PW0T0QrA z1+fUz6>3S~XoJeym#xbvdxktpL>+zc2Z-in5o=tj=?K+e}4mLXU*!Asr2$%S`sDa zFeto@eT!tI?)_Sz>t=ZBd+ZT=nV}rGx1Pr(2*hWY823BGpBudn(XdIM1DPMT=%6%^ z50Wj{->k(J@Pslv*HT$^DU=oi;l)?v0SkH>HPl=jk|KC1_9ZE@ch46dvN%n=CZ#-c z;6NTNSAk-U(70i{oW)H+TKB*Z&cQX7*v_!+K&1p#%~r&$1ud zI^^2qhsbm4ua;aJUn@n75o1Ozyu!|DXeQ5AsPj5`+t|)T8zNmb6q#o4C2x*Qm+;{} zC_6bsrs&&!`x3rA;U}05fnF1V-d+?8&CIFz*qFj3QS6}JN`e~PT8O*;e?JUU)(OES zY`{41r!71G#u^FU2}H9YYdn5UF{`Sxtvw6OF!&rTe4I9Wf+e7sQ;I}HYhm65K4LhB zeWkdE$53Z629v8ax(yTDn4cy^P%m;XpY-}a!_ey^sMytG%JAtrXULzG54RYijle^k z!J|O6?qkCeLu{=ol*JU>$5&X>7*mAL#dhM#niVO`$TE2Aa=c=_H^W@^HDg0udz7V^ z;lA+iFk)rbawZ*p8Z0I^xk;*^_nr7CV>bw%z

7u5cI=@7}oY=S-i#zwz$BQfvpQ zeppNOA04Io{>55eQ*0M`y_V`fx 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 000000000..8c50a5aae --- /dev/null +++ b/mobile/lib/domain/services/map.service.dart @@ -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> 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> Function(LatLngBounds? bounds) get getMarkers => _markerSource; +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 9fa4106d1..53a8bc671 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,8 @@ 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/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index e8c754134..87c32461d 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -95,6 +95,7 @@ class ExifInfo { ); } +@TableIndex(name: 'idx_lat_lng', columns: {#latitude, #longitude}) class RemoteExifEntity extends Table with DriftDefaultsMixin { const RemoteExifEntity(); diff --git a/mobile/lib/infrastructure/entities/exif.entity.drift.dart b/mobile/lib/infrastructure/entities/exif.entity.drift.dart index d45d6e8ef6b0058dc09c7c0222011b7cdbd8a8dd..c31050c321dd162a52aec8a90d892f009ff5805f 100644 GIT binary patch delta 157 zcmccio%zNO<_#T5^_d2Go_Q&$6$_r&3t0dF diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 0458a5b25..17cd590d7 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -21,7 +21,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; -import 'package:isar/isar.dart'; +import 'package:isar/isar.dart' hide Index; import 'db.repository.drift.dart'; @@ -66,7 +66,7 @@ class Drift extends $Drift implements IDatabaseRepository { : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); @override - int get schemaVersion => 6; + int get schemaVersion => 7; @override MigrationStrategy get migration => MigrationStrategy( @@ -112,6 +112,9 @@ class Drift extends $Drift implements IDatabaseRepository { await m.create(v6.uQRemoteAssetsOwnerChecksum); await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum); }, + from6To7: (m, v7) async { + await m.createIndex(v7.idxLatLng); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 2b7203eb464741e6ac6e0db1f4d46e31c916f325..fd170fc22d733db2594458da807f02fa7dcc618e 100644 GIT binary patch delta 27 icmbOmyFPXUt013(LZ+o&W=e%mVu?>)`esJKIkEtN>gorRL_Bq{gRKWTwTZ=9OfYR4Qlyr87%PQ&M#la`N-i!Awnckl~uv zT(w-=&ss4qV4SXO!)PXCu27tuk(!&RsgPEbpKBJ9Z@xX-hVdygTXJG?s)E_{1@?^c wjONo1*fS=ufMloV*)u9Lns4`TVElp8i0O7tjQiyj;6_@(SztHlZ~>hG062 _mapQueryBuilder( + assetFilter: (row) => + row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), + ); + + MapQuery _mapQueryBuilder({Expression Function($RemoteAssetEntityTable row)? assetFilter}) { + return (markerSource: (bounds) => _watchMapMarker(assetFilter: assetFilter, bounds: bounds)); + } + + Future> _watchMapMarker({ + Expression 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 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; + } +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 663db9b82..b4188f7ac 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,63 @@ 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.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.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/drift_map.page.dart b/mobile/lib/presentation/pages/drift_map.page.dart new file mode 100644 index 000000000..30da6410b --- /dev/null +++ b/mobile/lib/presentation/pages/drift_map.page.dart @@ -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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart index e85bb90d5..f540cbd46 100644 --- a/mobile/lib/presentation/pages/drift_place.page.dart +++ b/mobile/lib/presentation/pages/drift_place.page.dart @@ -92,9 +92,8 @@ class _Map extends StatelessWidget { child: SizedBox( height: 200, width: context.width, - // TODO: migrate to DriftMapRoute after merging #19898 child: MapThumbnail( - onTap: (_, __) => context.pushRoute(MapRoute(initialLocation: currentLocation)), + onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)), zoom: 8, centre: currentLocation ?? const LatLng(21.44950, -157.91959), showAttribution: false, diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index 82f2e1c3b..acbf2ad74 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -69,7 +69,7 @@ class _BaseDraggableScrollableSheetState extends ConsumerState shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, builder: (BuildContext context, ScrollController scrollController) { return Card( - color: widget.backgroundColor ?? context.colorScheme.surfaceContainerHigh, + color: widget.backgroundColor ?? context.colorScheme.surface, borderOnForeground: false, clipBehavior: Clip.antiAlias, elevation: 6.0, @@ -78,25 +78,21 @@ class _BaseDraggableScrollableSheetState extends ConsumerState child: CustomScrollView( controller: scrollController, slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - const SizedBox(height: 10), - const _DragHandle(), - const SizedBox(height: 14), - if (widget.actions.isNotEmpty) + const SliverPersistentHeader(delegate: _DragHandleDelegate(), pinned: true), + if (widget.actions.isNotEmpty) + SliverToBoxAdapter( + child: Column( + children: [ SizedBox( height: 115, child: ListView(shrinkWrap: true, scrollDirection: Axis.horizontal, children: widget.actions), ), - if (widget.actions.isNotEmpty) ...[ const Divider(indent: 16, endIndent: 16), const SizedBox(height: 16), ], - ], + ), ), - ), - ...(widget.slivers ?? []), + if (widget.slivers != null) ...widget.slivers!, ], ), ); @@ -105,17 +101,42 @@ class _BaseDraggableScrollableSheetState extends ConsumerState } } +class _DragHandleDelegate extends SliverPersistentHeaderDelegate { + const _DragHandleDelegate(); + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return const _DragHandle(); + } + + @override + bool shouldRebuild(_DragHandleDelegate oldDelegate) => false; + + @override + double get minExtent => 50.0; + + @override + double get maxExtent => 50.0; +} + class _DragHandle extends StatelessWidget { const _DragHandle(); @override Widget build(BuildContext context) { - return Container( - height: 6, - width: 32, - decoration: BoxDecoration( - color: context.themeData.dividerColor.lighten(amount: 0.6), - borderRadius: const BorderRadius.all(Radius.circular(20)), + return SizedBox( + height: 50, + child: Center( + child: SizedBox( + width: 32, + height: 6, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + color: context.themeData.dividerColor.lighten(amount: 0.6), + ), + ), + ), ), ); } 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 000000000..0017621c3 --- /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'; + +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), + ); + } +} 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 000000000..b849f954a --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -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 { + 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, LatLngBounds?>((ref, bounds) async { + final mapService = ref.watch(mapServiceProvider); + final markers = await mapService.getMarkers(bounds); + final features = List.filled(markers.length, const {}); + 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.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 000000000..6eab5741d --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -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 data; + const CustomSourceProperties({required this.data}); + + @override + Map 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 createState() => _DriftMapState(); +} + +class _DriftMapState extends ConsumerState { + 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 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 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 reloadMarkers(Map markers) async { + final controller = mapController; + if (controller == null || !mounted) { + return; + } + + await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers); + } + + 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; + } + + 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), + ), + ); + } +} 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 000000000..1c18fc48d --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map_utils.dart @@ -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: () {}, + ); +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 94e13c4e9..b35aac59e 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -107,14 +107,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { super.initState(); _eventSubscription = EventStream.shared.listen(_onEvent); - WidgetsBinding.instance.addPostFrameCallback((_) { - final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); - setState(() { - _perRow = currentTilesPerRow; - _scaleFactor = 7.0 - _perRow; - _baseScaleFactor = _scaleFactor; - }); - }); + final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); + _perRow = currentTilesPerRow; + _scaleFactor = 7.0 - _perRow; + _baseScaleFactor = _scaleFactor; ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); } diff --git a/mobile/lib/providers/infrastructure/map.provider.dart b/mobile/lib/providers/infrastructure/map.provider.dart new file mode 100644 index 000000000..e774cec75 --- /dev/null +++ b/mobile/lib/providers/infrastructure/map.provider.dart @@ -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((ref) => DriftMapRepository(ref.watch(driftProvider))); + +final mapServiceProvider = Provider( + (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((ref) => MapFactory(mapRepository: ref.watch(mapRepositoryProvider))); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index e75da235d..da2461782 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -89,6 +89,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'; @@ -331,6 +332,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftAlbumOptionsRoute.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 f5b3728ff..8c5064e75 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -892,6 +892,45 @@ class DriftLockedFolderRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftMapPage] +class DriftMapRoute extends PageRouteInfo { + DriftMapRoute({ + Key? key, + LatLng? initialLocation, + List? children, + }) : super( + DriftMapRoute.name, + args: DriftMapRouteArgs(key: key, initialLocation: initialLocation), + initialChildren: children, + ); + + static const String name = 'DriftMapRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const DriftMapRouteArgs(), + ); + return DriftMapPage(key: args.key, initialLocation: args.initialLocation); + }, + ); +} + +class DriftMapRouteArgs { + const DriftMapRouteArgs({this.key, this.initialLocation}); + + final Key? key; + + final LatLng? initialLocation; + + @override + String toString() { + return 'DriftMapRouteArgs{key: $key, initialLocation: $initialLocation}'; + } +} + /// generated route for /// [DriftMemoryPage] class DriftMemoryRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/async_mutex.dart b/mobile/lib/utils/async_mutex.dart index c61e6b13f..b97ab9b05 100644 --- a/mobile/lib/utils/async_mutex.dart +++ b/mobile/lib/utils/async_mutex.dart @@ -5,7 +5,7 @@ class AsyncMutex { Future _running = Future.value(null); int _enqueued = 0; - get enqueued => _enqueued; + int get enqueued => _enqueued; /// Execute [operation] exclusively, after any currently running operations. /// Returns a [Future] with the result of the [operation]. diff --git a/mobile/lib/utils/debounce.dart b/mobile/lib/utils/debounce.dart index 5451b569c..4c8060142 100644 --- a/mobile/lib/utils/debounce.dart +++ b/mobile/lib/utils/debounce.dart @@ -27,8 +27,9 @@ class Debouncer { } Future? drain() { - if (_timer != null && _timer!.isActive) { - _timer!.cancel(); + final timer = _timer; + if (timer != null && timer.isActive) { + timer.cancel(); if (_lastAction != null) { _callAndRest(); } diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index d59002bf5627106385c722877298425dce1668b9..87de9194d30654de6a41431b443a343168c811f4 100644 GIT binary patch delta 94 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 literal 0 HcmV?d00001