feat(mobile): sqlite asset viewer (#19552)

* add full image provider and refactor thumb providers

* photo_view updates

* wip: asset-viewer

* fix controller dispose on page change

* wip: bottom sheet

* fix interactions

* more bottomsheet changes

* generate schema

* PR feedback

* refactor asset viewer

* never rotate and fix background on page change

* use photoview as the loading builder

* precache after delay

* claude: optimizing rebuild of image provider

* claude: optimizing image decoding and caching

* use proper cache for new full size image providers

* chore: load local HEIC fullsize for iOS

* make controller callbacks nullable

* remove imageprovider cache

* do not handle drag gestures when zoomed

* use loadOriginal setting for HEIC / larger images

* preload assets outside timer

* never use same controllers in photo-view gallery

* fix: cannot scroll down once swipe with bottom sheet

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2025-07-02 23:54:37 +05:30 committed by GitHub
parent ec603a008c
commit 7855974a29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1867 additions and 490 deletions

View file

@ -1,5 +1,6 @@
import 'package:drift/drift.dart' hide Query;
import 'package:immich_mobile/domain/models/exif.model.dart' as domain;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
@ -132,6 +133,8 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
TextColumn get model => text().nullable()();
TextColumn get lens => text().nullable()();
TextColumn get orientation => text().nullable()();
TextColumn get timeZone => text().nullable()();
@ -143,3 +146,27 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin {
@override
Set<Column> get primaryKey => {assetId};
}
extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
domain.ExifInfo toDto() => domain.ExifInfo(
fileSize: fileSize,
dateTimeOriginal: dateTimeOriginal,
timeZone: timeZone,
make: make,
model: model,
iso: iso,
city: city,
state: state,
country: country,
description: description,
orientation: orientation,
latitude: latitude,
longitude: longitude,
f: fNumber?.toDouble(),
mm: focalLength?.toDouble(),
lens: lens,
width: width?.toDouble(),
height: height?.toDouble(),
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
);
}

View file

@ -27,6 +27,7 @@ typedef $$RemoteExifEntityTableCreateCompanionBuilder
i0.Value<int?> iso,
i0.Value<String?> make,
i0.Value<String?> model,
i0.Value<String?> lens,
i0.Value<String?> orientation,
i0.Value<String?> timeZone,
i0.Value<int?> rating,
@ -51,6 +52,7 @@ typedef $$RemoteExifEntityTableUpdateCompanionBuilder
i0.Value<int?> iso,
i0.Value<String?> make,
i0.Value<String?> model,
i0.Value<String?> lens,
i0.Value<String?> orientation,
i0.Value<String?> timeZone,
i0.Value<int?> rating,
@ -150,6 +152,9 @@ class $$RemoteExifEntityTableFilterComposer
i0.ColumnFilters<String> get model => $composableBuilder(
column: $table.model, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get lens => $composableBuilder(
column: $table.lens, builder: (column) => i0.ColumnFilters(column));
i0.ColumnFilters<String> get orientation => $composableBuilder(
column: $table.orientation,
builder: (column) => i0.ColumnFilters(column));
@ -249,6 +254,9 @@ class $$RemoteExifEntityTableOrderingComposer
i0.ColumnOrderings<String> get model => $composableBuilder(
column: $table.model, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get lens => $composableBuilder(
column: $table.lens, builder: (column) => i0.ColumnOrderings(column));
i0.ColumnOrderings<String> get orientation => $composableBuilder(
column: $table.orientation,
builder: (column) => i0.ColumnOrderings(column));
@ -345,6 +353,9 @@ class $$RemoteExifEntityTableAnnotationComposer
i0.GeneratedColumn<String> get model =>
$composableBuilder(column: $table.model, builder: (column) => column);
i0.GeneratedColumn<String> get lens =>
$composableBuilder(column: $table.lens, builder: (column) => column);
i0.GeneratedColumn<String> get orientation => $composableBuilder(
column: $table.orientation, builder: (column) => column);
@ -424,6 +435,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
i0.Value<int?> iso = const i0.Value.absent(),
i0.Value<String?> make = const i0.Value.absent(),
i0.Value<String?> model = const i0.Value.absent(),
i0.Value<String?> lens = const i0.Value.absent(),
i0.Value<String?> orientation = const i0.Value.absent(),
i0.Value<String?> timeZone = const i0.Value.absent(),
i0.Value<int?> rating = const i0.Value.absent(),
@ -447,6 +459,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
iso: iso,
make: make,
model: model,
lens: lens,
orientation: orientation,
timeZone: timeZone,
rating: rating,
@ -470,6 +483,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
i0.Value<int?> iso = const i0.Value.absent(),
i0.Value<String?> make = const i0.Value.absent(),
i0.Value<String?> model = const i0.Value.absent(),
i0.Value<String?> lens = const i0.Value.absent(),
i0.Value<String?> orientation = const i0.Value.absent(),
i0.Value<String?> timeZone = const i0.Value.absent(),
i0.Value<int?> rating = const i0.Value.absent(),
@ -493,6 +507,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager<
iso: iso,
make: make,
model: model,
lens: lens,
orientation: orientation,
timeZone: timeZone,
rating: rating,
@ -666,6 +681,12 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
late final i0.GeneratedColumn<String> model = i0.GeneratedColumn<String>(
'model', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _lensMeta =
const i0.VerificationMeta('lens');
@override
late final i0.GeneratedColumn<String> lens = i0.GeneratedColumn<String>(
'lens', aliasedName, true,
type: i0.DriftSqlType.string, requiredDuringInsert: false);
static const i0.VerificationMeta _orientationMeta =
const i0.VerificationMeta('orientation');
@override
@ -709,6 +730,7 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
iso,
make,
model,
lens,
orientation,
timeZone,
rating,
@ -803,6 +825,10 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
context.handle(
_modelMeta, model.isAcceptableOrUnknown(data['model']!, _modelMeta));
}
if (data.containsKey('lens')) {
context.handle(
_lensMeta, lens.isAcceptableOrUnknown(data['lens']!, _lensMeta));
}
if (data.containsKey('orientation')) {
context.handle(
_orientationMeta,
@ -868,6 +894,8 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity
.read(i0.DriftSqlType.string, data['${effectivePrefix}make']),
model: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}model']),
lens: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}lens']),
orientation: attachedDatabase.typeMapping
.read(i0.DriftSqlType.string, data['${effectivePrefix}orientation']),
timeZone: attachedDatabase.typeMapping
@ -909,6 +937,7 @@ class RemoteExifEntityData extends i0.DataClass
final int? iso;
final String? make;
final String? model;
final String? lens;
final String? orientation;
final String? timeZone;
final int? rating;
@ -931,6 +960,7 @@ class RemoteExifEntityData extends i0.DataClass
this.iso,
this.make,
this.model,
this.lens,
this.orientation,
this.timeZone,
this.rating,
@ -987,6 +1017,9 @@ class RemoteExifEntityData extends i0.DataClass
if (!nullToAbsent || model != null) {
map['model'] = i0.Variable<String>(model);
}
if (!nullToAbsent || lens != null) {
map['lens'] = i0.Variable<String>(lens);
}
if (!nullToAbsent || orientation != null) {
map['orientation'] = i0.Variable<String>(orientation);
}
@ -1024,6 +1057,7 @@ class RemoteExifEntityData extends i0.DataClass
iso: serializer.fromJson<int?>(json['iso']),
make: serializer.fromJson<String?>(json['make']),
model: serializer.fromJson<String?>(json['model']),
lens: serializer.fromJson<String?>(json['lens']),
orientation: serializer.fromJson<String?>(json['orientation']),
timeZone: serializer.fromJson<String?>(json['timeZone']),
rating: serializer.fromJson<int?>(json['rating']),
@ -1051,6 +1085,7 @@ class RemoteExifEntityData extends i0.DataClass
'iso': serializer.toJson<int?>(iso),
'make': serializer.toJson<String?>(make),
'model': serializer.toJson<String?>(model),
'lens': serializer.toJson<String?>(lens),
'orientation': serializer.toJson<String?>(orientation),
'timeZone': serializer.toJson<String?>(timeZone),
'rating': serializer.toJson<int?>(rating),
@ -1076,6 +1111,7 @@ class RemoteExifEntityData extends i0.DataClass
i0.Value<int?> iso = const i0.Value.absent(),
i0.Value<String?> make = const i0.Value.absent(),
i0.Value<String?> model = const i0.Value.absent(),
i0.Value<String?> lens = const i0.Value.absent(),
i0.Value<String?> orientation = const i0.Value.absent(),
i0.Value<String?> timeZone = const i0.Value.absent(),
i0.Value<int?> rating = const i0.Value.absent(),
@ -1101,6 +1137,7 @@ class RemoteExifEntityData extends i0.DataClass
iso: iso.present ? iso.value : this.iso,
make: make.present ? make.value : this.make,
model: model.present ? model.value : this.model,
lens: lens.present ? lens.value : this.lens,
orientation: orientation.present ? orientation.value : this.orientation,
timeZone: timeZone.present ? timeZone.value : this.timeZone,
rating: rating.present ? rating.value : this.rating,
@ -1132,6 +1169,7 @@ class RemoteExifEntityData extends i0.DataClass
iso: data.iso.present ? data.iso.value : this.iso,
make: data.make.present ? data.make.value : this.make,
model: data.model.present ? data.model.value : this.model,
lens: data.lens.present ? data.lens.value : this.lens,
orientation:
data.orientation.present ? data.orientation.value : this.orientation,
timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone,
@ -1162,6 +1200,7 @@ class RemoteExifEntityData extends i0.DataClass
..write('iso: $iso, ')
..write('make: $make, ')
..write('model: $model, ')
..write('lens: $lens, ')
..write('orientation: $orientation, ')
..write('timeZone: $timeZone, ')
..write('rating: $rating, ')
@ -1189,6 +1228,7 @@ class RemoteExifEntityData extends i0.DataClass
iso,
make,
model,
lens,
orientation,
timeZone,
rating,
@ -1215,6 +1255,7 @@ class RemoteExifEntityData extends i0.DataClass
other.iso == this.iso &&
other.make == this.make &&
other.model == this.model &&
other.lens == this.lens &&
other.orientation == this.orientation &&
other.timeZone == this.timeZone &&
other.rating == this.rating &&
@ -1240,6 +1281,7 @@ class RemoteExifEntityCompanion
final i0.Value<int?> iso;
final i0.Value<String?> make;
final i0.Value<String?> model;
final i0.Value<String?> lens;
final i0.Value<String?> orientation;
final i0.Value<String?> timeZone;
final i0.Value<int?> rating;
@ -1262,6 +1304,7 @@ class RemoteExifEntityCompanion
this.iso = const i0.Value.absent(),
this.make = const i0.Value.absent(),
this.model = const i0.Value.absent(),
this.lens = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.timeZone = const i0.Value.absent(),
this.rating = const i0.Value.absent(),
@ -1285,6 +1328,7 @@ class RemoteExifEntityCompanion
this.iso = const i0.Value.absent(),
this.make = const i0.Value.absent(),
this.model = const i0.Value.absent(),
this.lens = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.timeZone = const i0.Value.absent(),
this.rating = const i0.Value.absent(),
@ -1308,6 +1352,7 @@ class RemoteExifEntityCompanion
i0.Expression<int>? iso,
i0.Expression<String>? make,
i0.Expression<String>? model,
i0.Expression<String>? lens,
i0.Expression<String>? orientation,
i0.Expression<String>? timeZone,
i0.Expression<int>? rating,
@ -1331,6 +1376,7 @@ class RemoteExifEntityCompanion
if (iso != null) 'iso': iso,
if (make != null) 'make': make,
if (model != null) 'model': model,
if (lens != null) 'lens': lens,
if (orientation != null) 'orientation': orientation,
if (timeZone != null) 'time_zone': timeZone,
if (rating != null) 'rating': rating,
@ -1356,6 +1402,7 @@ class RemoteExifEntityCompanion
i0.Value<int?>? iso,
i0.Value<String?>? make,
i0.Value<String?>? model,
i0.Value<String?>? lens,
i0.Value<String?>? orientation,
i0.Value<String?>? timeZone,
i0.Value<int?>? rating,
@ -1378,6 +1425,7 @@ class RemoteExifEntityCompanion
iso: iso ?? this.iso,
make: make ?? this.make,
model: model ?? this.model,
lens: lens ?? this.lens,
orientation: orientation ?? this.orientation,
timeZone: timeZone ?? this.timeZone,
rating: rating ?? this.rating,
@ -1439,6 +1487,9 @@ class RemoteExifEntityCompanion
if (model.present) {
map['model'] = i0.Variable<String>(model.value);
}
if (lens.present) {
map['lens'] = i0.Variable<String>(lens.value);
}
if (orientation.present) {
map['orientation'] = i0.Variable<String>(orientation.value);
}
@ -1474,6 +1525,7 @@ class RemoteExifEntityCompanion
..write('iso: $iso, ')
..write('make: $make, ')
..write('model: $model, ')
..write('lens: $lens, ')
..write('orientation: $orientation, ')
..write('timeZone: $timeZone, ')
..write('rating: $rating, ')

View file

@ -52,8 +52,8 @@ mergedBucket(:group_by AS INTEGER):
SELECT
COUNT(*) as asset_count,
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at, 'localtime') -- day
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at, 'localtime') -- month
END AS bucket_date
FROM
(

View file

@ -51,7 +51,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
$arrayStartIndex += var2.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at) END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in var2) i0.Variable<String>($)

View file

@ -1,8 +1,6 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
as entity;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart';
@ -43,36 +41,3 @@ class IsarExifRepository extends IsarDatabaseRepository {
});
}
}
class DriftRemoteExifRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftRemoteExifRepository(this._db) : super(_db);
Future<ExifInfo?> get(String assetId) {
final query = _db.remoteExifEntity.select()
..where((exif) => exif.assetId.equals(assetId));
return query.map((asset) => asset.toDto()).getSingleOrNull();
}
}
extension on RemoteExifEntityData {
ExifInfo toDto() {
return ExifInfo(
fileSize: fileSize,
description: description,
orientation: orientation,
timeZone: timeZone,
dateTimeOriginal: dateTimeOriginal,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
country: country,
make: make,
model: model,
f: fNumber,
iso: iso,
);
}
}

View file

@ -1,13 +1,23 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
hide ExifInfo;
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 DriftRemoteAssetRepository extends DriftDatabaseRepository {
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftRemoteAssetRepository(this._db) : super(_db);
const RemoteAssetRepository(this._db) : super(_db);
Future<ExifInfo?> getExif(String id) {
return _db.managers.remoteExifEntity
.filter((row) => row.assetId.id.equals(id))
.map((row) => row.toDto())
.getSingleOrNull();
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) {
return _db.batch((batch) async {

View file

@ -5,20 +5,21 @@ import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class StorageRepository {
final _log = Logger('StorageRepository');
const StorageRepository();
Future<File?> getFileForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
File? file;
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFile;
if (file == null) {
_log.warning(
log.warning(
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
}
} catch (error, stackTrace) {
_log.warning(
log.warning(
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,

View file

@ -161,8 +161,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
fNumber: Value(exif.fNumber),
fileSize: Value(exif.fileSizeInByte),
focalLength: Value(exif.focalLength),
latitude: Value(exif.latitude),
longitude: Value(exif.longitude),
latitude: Value(exif.latitude?.toDouble()),
longitude: Value(exif.longitude?.toDouble()),
iso: Value(exif.iso),
make: Value(exif.make),
model: Value(exif.model),
@ -170,6 +170,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
timeZone: Value(exif.timeZone),
rating: Value(exif.rating),
projectionType: Value(exif.projectionType),
lens: Value(exif.lensModel),
);
batch.insert(