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>
This commit is contained in:
Mert 2025-08-06 11:05:49 -04:00 committed by GitHub
parent 1ca46fbd98
commit 0121043d7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 7573 additions and 34 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -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<List<BaseAsset>> Function(int index, int count);
@ -57,6 +58,8 @@ class TimelineFactory {
TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets) => TimelineService(_timelineRepository.fromAssets(assets));
TimelineService map(LatLngBounds bounds) => TimelineService(_timelineRepository.map(bounds, groupBy));
}
class TimelineService {

View File

@ -95,6 +95,7 @@ class ExifInfo {
);
}
@TableIndex(name: 'idx_lat_lng', columns: {#latitude, #longitude})
class RemoteExifEntity extends Table with DriftDefaultsMixin {
const RemoteExifEntity();

View File

@ -699,6 +699,10 @@ typedef $$RemoteExifEntityTableProcessedTableManager =
i1.RemoteExifEntityData,
i0.PrefetchHooks Function({bool assetId})
>;
i0.Index get idxLatLng => i0.Index(
'idx_lat_lng',
'CREATE INDEX idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
class $RemoteExifEntityTable extends i2.RemoteExifEntity
with i0.TableInfo<$RemoteExifEntityTable, i1.RemoteExifEntityData> {

View File

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

View File

@ -98,6 +98,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
memoryAssetEntity,
personEntity,
assetFaceEntity,
i9.idxLatLng,
];
@override
i0.StreamQueryUpdateRules

View File

@ -2705,12 +2705,357 @@ i1.GeneratedColumn<String> _column_86(String aliasedName) =>
true,
type: i1.DriftSqlType.string,
);
final class Schema7 extends i0.VersionedSchema {
Schema7({required super.database}) : super(version: 7);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
idxLatLng,
];
late final Shape16 userEntity = Shape16(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_84,
_column_85,
_column_5,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape6 localAlbumEntity = Shape6(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -2739,6 +3084,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from5To6(migrator, schema);
return 6;
case 6:
final schema = Schema7(database: database);
final migrator = i1.Migrator(database, schema);
await from6To7(migrator, schema);
return 7;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -2751,6 +3101,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -2758,5 +3109,6 @@ i1.OnUpgrade stepByStep({
from3To4: from3To4,
from4To5: from4To5,
from5To6: from5To6,
from6To7: from6To7,
),
);

View File

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

View File

@ -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<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.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.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<bool> Function($RemoteAssetEntityTable row) filter,
GroupAssetsBy groupBy = GroupAssetsBy.day,

View File

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

View File

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

View File

@ -69,7 +69,7 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
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<BaseBottomSheet>
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<BaseBottomSheet>
}
}
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),
),
),
),
),
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '/'),

View File

@ -892,6 +892,45 @@ class DriftLockedFolderRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftMapPage]
class DriftMapRoute extends PageRouteInfo<DriftMapRouteArgs> {
DriftMapRoute({
Key? key,
LatLng? initialLocation,
List<PageRouteInfo>? 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<DriftMapRouteArgs>(
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<DriftMemoryRouteArgs> {

View File

@ -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].

View File

@ -27,8 +27,9 @@ class Debouncer {
}
Future<void>? drain() {
if (_timer != null && _timer!.isActive) {
_timer!.cancel();
final timer = _timer;
if (timer != null && timer.isActive) {
timer.cancel();
if (_lastAction != null) {
_callAndRest();
}

View File

@ -9,6 +9,7 @@ import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -26,10 +27,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v5.DatabaseAtV5(db);
case 6:
return v6.DatabaseAtV6(db);
case 7:
return v7.DatabaseAtV7(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6];
static const versions = const [1, 2, 3, 4, 5, 6, 7];
}

File diff suppressed because it is too large Load Diff