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

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,