add thumbhash support to mobile app

This commit is contained in:
Luke McCarthy 2023-06-21 22:49:19 -04:00
parent 48e4ea5231
commit 000c2bf43f
6 changed files with 305 additions and 57 deletions

View file

@ -30,7 +30,8 @@ class Asset {
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite,
isArchived = remote.isArchived;
isArchived = remote.isArchived,
thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
@ -74,6 +75,7 @@ class Asset {
this.exifInfo,
required this.isFavorite,
required this.isArchived,
required this.thumbhash,
});
@ignore
@ -108,6 +110,8 @@ class Asset {
)
String checksum;
String? thumbhash;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@ -180,6 +184,7 @@ class Asset {
if (other is! Asset) return false;
return id == other.id &&
checksum == other.checksum &&
thumbhash == other.thumbhash &&
remoteId == other.remoteId &&
localId == other.localId &&
ownerId == other.ownerId &&
@ -202,6 +207,7 @@ class Asset {
int get hashCode =>
id.hashCode ^
checksum.hashCode ^
thumbhash.hashCode ^
remoteId.hashCode ^
localId.hashCode ^
ownerId.hashCode ^
@ -222,6 +228,7 @@ class Asset {
bool canUpdate(Asset a) {
assert(isInDb);
assert(checksum == a.checksum);
assert(thumbhash == a.thumbhash);
assert(a.storage != AssetState.merged);
return a.updatedAt.isAfter(updatedAt) ||
a.isRemote && !isRemote ||
@ -292,6 +299,7 @@ class Asset {
Asset _copyWith({
Id? id,
String? checksum,
String? thumbhash,
String? remoteId,
String? localId,
int? ownerId,
@ -311,6 +319,7 @@ class Asset {
Asset(
id: id ?? this.id,
checksum: checksum ?? this.checksum,
thumbhash: thumbhash ?? this.thumbhash,
remoteId: remoteId ?? this.remoteId,
localId: localId ?? this.localId,
ownerId: ownerId ?? this.ownerId,
@ -365,6 +374,7 @@ class Asset {
"remoteId": "${remoteId ?? "N/A"}",
"localId": "${localId ?? "N/A"}",
"checksum": "$checksum",
"thumbhash": "$thumbhash",
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",

View file

@ -77,19 +77,24 @@ const AssetSchema = CollectionSchema(
name: r'remoteId',
type: IsarType.string,
),
r'type': PropertySchema(
r'thumbhash': PropertySchema(
id: 12,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
id: 13,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 13,
id: 14,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 14,
id: 15,
name: r'width',
type: IsarType.int,
)
@ -179,6 +184,12 @@ int _assetEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.thumbhash;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount;
}
@ -200,9 +211,10 @@ void _assetSerialize(
writer.writeString(offsets[9], object.localId);
writer.writeLong(offsets[10], object.ownerId);
writer.writeString(offsets[11], object.remoteId);
writer.writeByte(offsets[12], object.type.index);
writer.writeDateTime(offsets[13], object.updatedAt);
writer.writeInt(offsets[14], object.width);
writer.writeString(offsets[12], object.thumbhash);
writer.writeByte(offsets[13], object.type.index);
writer.writeDateTime(offsets[14], object.updatedAt);
writer.writeInt(offsets[15], object.width);
}
Asset _assetDeserialize(
@ -225,10 +237,11 @@ Asset _assetDeserialize(
localId: reader.readStringOrNull(offsets[9]),
ownerId: reader.readLong(offsets[10]),
remoteId: reader.readStringOrNull(offsets[11]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
thumbhash: reader.readStringOrNull(offsets[12]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[13]),
width: reader.readIntOrNull(offsets[14]),
updatedAt: reader.readDateTime(offsets[14]),
width: reader.readIntOrNull(offsets[15]),
);
return object;
}
@ -265,11 +278,13 @@ P _assetDeserializeProp<P>(
case 11:
return (reader.readStringOrNull(offset)) as P;
case 12:
return (reader.readStringOrNull(offset)) as P;
case 13:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 13:
return (reader.readDateTime(offset)) as P;
case 14:
return (reader.readDateTime(offset)) as P;
case 15:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@ -1786,6 +1801,152 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'thumbhash',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'thumbhash',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'thumbhash',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'thumbhash',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'thumbhash',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'thumbhash',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
AssetType value) {
return QueryBuilder.apply(this, (query) {
@ -2110,6 +2271,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhash() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhashDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@ -2304,6 +2477,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhash() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhashDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@ -2420,6 +2605,13 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByThumbhash(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'thumbhash', caseSensitive: caseSensitive);
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type');
@ -2518,6 +2710,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, String?, QQueryOperations> thumbhashProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'thumbhash');
});
}
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type');

View file

@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_thumbhash/flutter_thumbhash.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@ -86,6 +87,7 @@ class ImmichImage extends StatelessWidget {
}
final String? token = Store.get(StoreKey.accessToken);
final String thumbnailRequestUrl = getThumbnailUrl(asset);
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"},
@ -98,7 +100,14 @@ class ImmichImage extends StatelessWidget {
fit: fit,
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
if (asset.thumbhash != null) {
return FittedBox(
fit: BoxFit.fill,
child: Image(
image: ThumbHash.fromBase64(asset.thumbhash!).toImage(),
),
);
} else if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);

View file

@ -52,61 +52,64 @@ class UserResponseDto {
String oauthId;
@override
bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
other.id == id &&
other.email == email &&
other.firstName == firstName &&
other.lastName == lastName &&
other.storageLabel == storageLabel &&
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin &&
other.createdAt == createdAt &&
other.deletedAt == deletedAt &&
other.updatedAt == updatedAt &&
other.oauthId == oauthId;
bool operator ==(Object other) =>
identical(this, other) ||
other is UserResponseDto &&
other.id == id &&
other.email == email &&
other.firstName == firstName &&
other.lastName == lastName &&
other.storageLabel == storageLabel &&
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin &&
other.createdAt == createdAt &&
other.deletedAt == deletedAt &&
other.updatedAt == updatedAt &&
other.oauthId == oauthId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(email.hashCode) +
(firstName.hashCode) +
(lastName.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
(isAdmin.hashCode) +
(createdAt.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(updatedAt.hashCode) +
(oauthId.hashCode);
// ignore: unnecessary_parenthesis
(id.hashCode) +
(email.hashCode) +
(firstName.hashCode) +
(lastName.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
(isAdmin.hashCode) +
(createdAt.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(updatedAt.hashCode) +
(oauthId.hashCode);
@override
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, createdAt=$createdAt, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
String toString() =>
'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, createdAt=$createdAt, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
json[r'email'] = this.email;
json[r'firstName'] = this.firstName;
json[r'lastName'] = this.lastName;
json[r'id'] = this.id;
json[r'email'] = this.email;
json[r'firstName'] = this.firstName;
json[r'lastName'] = this.lastName;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
// json[r'storageLabel'] = null;
}
json[r'profileImagePath'] = this.profileImagePath;
json[r'shouldChangePassword'] = this.shouldChangePassword;
json[r'isAdmin'] = this.isAdmin;
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
json[r'shouldChangePassword'] = this.shouldChangePassword;
json[r'isAdmin'] = this.isAdmin;
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
if (this.deletedAt != null) {
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'oauthId'] = this.oauthId;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'oauthId'] = this.oauthId;
return json;
}
@ -122,8 +125,10 @@ class UserResponseDto {
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
assert(json.containsKey(key),
'Required key "UserResponseDto[$key]" is missing from JSON.');
assert(json[key] != null,
'Required key "UserResponseDto[$key]" has a null value in JSON.');
});
return true;
}());
@ -135,7 +140,8 @@ class UserResponseDto {
lastName: mapValueOfType<String>(json, r'lastName')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
shouldChangePassword:
mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
createdAt: mapDateTime(json, r'createdAt', '')!,
deletedAt: mapDateTime(json, r'deletedAt', ''),
@ -146,7 +152,10 @@ class UserResponseDto {
return null;
}
static List<UserResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
static List<UserResponseDto> listFromJson(
dynamic json, {
bool growable = false,
}) {
final result = <UserResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@ -174,13 +183,19 @@ class UserResponseDto {
}
// maps a json object with a list of UserResponseDto-objects as value to a dart map
static Map<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
static Map<String, List<UserResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
final map = <String, List<UserResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UserResponseDto.listFromJson(entry.value, growable: growable,);
map[entry.key] = UserResponseDto.listFromJson(
entry.value,
growable: growable,
);
}
}
return map;
@ -202,4 +217,3 @@ class UserResponseDto {
'oauthId',
};
}

View file

@ -453,6 +453,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_thumbhash:
dependency: "direct main"
description:
name: flutter_thumbhash
sha256: "0a21f7fc4ffefe79f93b047780ad7d5af42a0f0a49b9ec5fb94005e6deebe4ad"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
flutter_udid:
dependency: "direct main"
description:
@ -1224,6 +1232,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.1"
thumbhash:
dependency: transitive
description:
name: thumbhash
sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
time:
dependency: transitive
description:

View file

@ -47,6 +47,7 @@ dependencies:
permission_handler: ^10.2.0
device_info_plus: ^8.1.0
crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
flutter_thumbhash: 0.1.0+1
openapi:
path: openapi