merge main

This commit is contained in:
shenlong-tanwen 2026-01-13 17:01:01 +05:30
commit 9a4bedaf2d
215 changed files with 18593 additions and 3324 deletions

View File

@ -95,11 +95,3 @@ Enter the cloud on the top right -> cog wheel on the top right -> select the syn
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
It will only reflect files you add.
:::
If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually.
To overcome this limitation, the files must be removed from the ignore list by
App settings -> Advanced -> Duplicate Assets -> Clear
:::info
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the ignore list again at the end of the synchronization.
:::

View File

@ -346,6 +346,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
duplicateId: null,
resized: true,
checksum: asset.checksum,
width: exifInfo.exifImageWidth ?? 1,
height: exifInfo.exifImageHeight ?? 1,
};
}

View File

@ -181,8 +181,12 @@ export const assetViewerUtils = {
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {

View File

@ -56,7 +56,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect
.poll(async () => {
@ -85,7 +85,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect
.poll(async () => {

View File

@ -833,6 +833,9 @@
"created_at": "Created",
"creating_linked_albums": "Creating linked albums...",
"crop": "Crop",
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_original": "Original",
"curated_object_page_title": "Things",
"current_device": "Current device",
"current_pin_code": "Current PIN code",
@ -966,9 +969,13 @@
"editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_mode": "Editor mode",
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
"editor_rotate_right": "Rotate 90° clockwise",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@ -1459,6 +1466,8 @@
"minimize": "Minimize",
"minute": "Minute",
"minutes": "Minutes",
"mirror_horizontal": "Horizontal",
"mirror_vertical": "Vertical",
"missing": "Missing",
"mobile_app": "Mobile App",
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
@ -99,9 +98,7 @@ class AssetService {
height = fetched?.height?.toDouble();
}
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
return (width: width, height: height, isFlipped: false);
}
Future<List<(String, String)>> getPlaces(String userId) {

View File

@ -4,6 +4,13 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
enum TrashOrigin {
// do not change this order!
localSync,
remoteSync,
localUser,
}
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)')
class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
@ -19,6 +26,8 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
IntColumn get orientation => integer().withDefault(const Constant(0))();
IntColumn get source => intEnum<TrashOrigin>()();
@override
Set<Column> get primaryKey => {id, albumId};
}

View File

@ -22,6 +22,7 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
required i3.TrashOrigin source,
});
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i1.TrashedLocalAssetEntityCompanion Function({
@ -37,6 +38,7 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum,
i0.Value<bool> isFavorite,
i0.Value<int> orientation,
i0.Value<i3.TrashOrigin> source,
});
class $$TrashedLocalAssetEntityTableFilterComposer
@ -109,6 +111,12 @@ class $$TrashedLocalAssetEntityTableFilterComposer
column: $table.orientation,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<i3.TrashOrigin, i3.TrashOrigin, int>
get source => $composableBuilder(
column: $table.source,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
}
class $$TrashedLocalAssetEntityTableOrderingComposer
@ -180,6 +188,11 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
column: $table.orientation,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get source => $composableBuilder(
column: $table.source,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$TrashedLocalAssetEntityTableAnnotationComposer
@ -233,6 +246,9 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
column: $table.orientation,
builder: (column) => column,
);
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
$composableBuilder(column: $table.source, builder: (column) => column);
}
class $$TrashedLocalAssetEntityTableTableManager
@ -293,6 +309,7 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
}) => i1.TrashedLocalAssetEntityCompanion(
name: name,
type: type,
@ -306,6 +323,7 @@ class $$TrashedLocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
source: source,
),
createCompanionCallback:
({
@ -321,6 +339,7 @@ class $$TrashedLocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(),
required i3.TrashOrigin source,
}) => i1.TrashedLocalAssetEntityCompanion.insert(
name: name,
type: type,
@ -334,6 +353,7 @@ class $$TrashedLocalAssetEntityTableTableManager
checksum: checksum,
isFavorite: isFavorite,
orientation: orientation,
source: source,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@ -519,6 +539,17 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
defaultValue: const i4.Constant(0),
);
@override
late final i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> source =
i0.GeneratedColumn<int>(
'source',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
).withConverter<i3.TrashOrigin>(
i1.$TrashedLocalAssetEntityTable.$convertersource,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
type,
@ -532,6 +563,7 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
checksum,
isFavorite,
orientation,
source,
];
@override
String get aliasedName => _alias ?? actualTableName;
@ -682,6 +714,12 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
i0.DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
),
);
}
@ -692,6 +730,8 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
@override
bool get withoutRowId => true;
@override
@ -712,6 +752,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final i3.TrashOrigin source;
const TrashedLocalAssetEntityData({
required this.name,
required this.type,
@ -725,6 +766,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
required this.source,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@ -753,6 +795,11 @@ class TrashedLocalAssetEntityData extends i0.DataClass
}
map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['orientation'] = i0.Variable<int>(orientation);
{
map['source'] = i0.Variable<int>(
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
);
}
return map;
}
@ -776,6 +823,9 @@ class TrashedLocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
serializer.fromJson<int>(json['source']),
),
);
}
@override
@ -796,6 +846,9 @@ class TrashedLocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'source': serializer.toJson<int>(
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
),
};
}
@ -812,6 +865,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite,
int? orientation,
i3.TrashOrigin? source,
}) => i1.TrashedLocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@ -827,6 +881,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
);
TrashedLocalAssetEntityData copyWithCompanion(
i1.TrashedLocalAssetEntityCompanion data,
@ -850,6 +905,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
source: data.source.present ? data.source.value : this.source,
);
}
@ -867,7 +923,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
..write('albumId: $albumId, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation')
..write('orientation: $orientation, ')
..write('source: $source')
..write(')'))
.toString();
}
@ -886,6 +943,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
checksum,
isFavorite,
orientation,
source,
);
@override
bool operator ==(Object other) =>
@ -902,7 +960,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
other.albumId == this.albumId &&
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation);
other.orientation == this.orientation &&
other.source == this.source);
}
class TrashedLocalAssetEntityCompanion
@ -919,6 +978,7 @@ class TrashedLocalAssetEntityCompanion
final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite;
final i0.Value<int> orientation;
final i0.Value<i3.TrashOrigin> source;
const TrashedLocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@ -932,6 +992,7 @@ class TrashedLocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
this.source = const i0.Value.absent(),
});
TrashedLocalAssetEntityCompanion.insert({
required String name,
@ -946,10 +1007,12 @@ class TrashedLocalAssetEntityCompanion
this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(),
required i3.TrashOrigin source,
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id),
albumId = i0.Value(albumId);
albumId = i0.Value(albumId),
source = i0.Value(source);
static i0.Insertable<i1.TrashedLocalAssetEntityData> custom({
i0.Expression<String>? name,
i0.Expression<int>? type,
@ -963,6 +1026,7 @@ class TrashedLocalAssetEntityCompanion
i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation,
i0.Expression<int>? source,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@ -977,6 +1041,7 @@ class TrashedLocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (source != null) 'source': source,
});
}
@ -993,6 +1058,7 @@ class TrashedLocalAssetEntityCompanion
i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite,
i0.Value<int>? orientation,
i0.Value<i3.TrashOrigin>? source,
}) {
return i1.TrashedLocalAssetEntityCompanion(
name: name ?? this.name,
@ -1007,6 +1073,7 @@ class TrashedLocalAssetEntityCompanion
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
);
}
@ -1051,6 +1118,11 @@ class TrashedLocalAssetEntityCompanion
if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value);
}
if (source.present) {
map['source'] = i0.Variable<int>(
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
);
}
return map;
}
@ -1068,7 +1140,8 @@ class TrashedLocalAssetEntityCompanion
..write('albumId: $albumId, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation')
..write('orientation: $orientation, ')
..write('source: $source')
..write(')'))
.toString();
}

View File

@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 15;
int get schemaVersion => 16;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -193,10 +193,13 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
from14To15: (m, v15) async {
await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source);
},
from15To16: (m, v16) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v15.localAssetEntity, v15.localAssetEntity.iCloudId);
await m.createIndex(v15.idxLocalAssetCloudId);
await m.createTable(v15.remoteAssetCloudIdEntity);
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
await m.createIndex(v16.idxLocalAssetCloudId);
await m.createTable(v16.remoteAssetCloudIdEntity);
},
),
);

View File

@ -5945,6 +5945,461 @@ i1.GeneratedColumn<DateTime> _column_96(String aliasedName) =>
final class Schema15 extends i0.VersionedSchema {
Schema15({required super.database}) : super(version: 15);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
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 Shape24 localAssetEntity = Shape24(
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,
_column_96,
_column_46,
_column_47,
],
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 Shape19 localAlbumEntity = Shape19(
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_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
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 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,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape25 extends i0.VersionedTable {
Shape25({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get source =>
columnsByName['source']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_97(String aliasedName) =>
i1.GeneratedColumn<int>(
'source',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
final class Schema16 extends i0.VersionedSchema {
Schema16({required super.database}) : super(version: 16);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
@ -6035,7 +6490,7 @@ final class Schema15 extends i0.VersionedSchema {
),
alias: null,
);
late final Shape25 localAssetEntity = Shape25(
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
@ -6053,7 +6508,7 @@ final class Schema15 extends i0.VersionedSchema {
_column_22,
_column_14,
_column_23,
_column_97,
_column_98,
_column_96,
_column_46,
_column_47,
@ -6237,7 +6692,7 @@ final class Schema15 extends i0.VersionedSchema {
),
alias: null,
);
late final Shape26 remoteAssetCloudIdEntity = Shape26(
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
@ -6245,8 +6700,8 @@ final class Schema15 extends i0.VersionedSchema {
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_98,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
@ -6345,7 +6800,7 @@ final class Schema15 extends i0.VersionedSchema {
),
alias: null,
);
late final Shape23 trashedLocalAssetEntity = Shape23(
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
@ -6364,6 +6819,7 @@ final class Schema15 extends i0.VersionedSchema {
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
@ -6383,8 +6839,8 @@ final class Schema15 extends i0.VersionedSchema {
);
}
class Shape25 extends i0.VersionedTable {
Shape25({required super.source, required super.alias}) : super.aliased();
class Shape26 extends i0.VersionedTable {
Shape26({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
@ -6417,7 +6873,7 @@ class Shape25 extends i0.VersionedTable {
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<String> _column_97(String aliasedName) =>
i1.GeneratedColumn<String> _column_98(String aliasedName) =>
i1.GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
@ -6425,8 +6881,8 @@ i1.GeneratedColumn<String> _column_97(String aliasedName) =>
type: i1.DriftSqlType.string,
);
class Shape26 extends i0.VersionedTable {
Shape26({required super.source, required super.alias}) : super.aliased();
class Shape27 extends i0.VersionedTable {
Shape27({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get cloudId =>
@ -6441,7 +6897,7 @@ class Shape26 extends i0.VersionedTable {
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<String> _column_98(String aliasedName) =>
i1.GeneratedColumn<String> _column_99(String aliasedName) =>
i1.GeneratedColumn<String>(
'cloud_id',
aliasedName,
@ -6449,7 +6905,7 @@ i1.GeneratedColumn<String> _column_98(String aliasedName) =>
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'),
);
i1.GeneratedColumn<DateTime> _column_99(String aliasedName) =>
i1.GeneratedColumn<DateTime> _column_100(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'created_at',
aliasedName,
@ -6471,6 +6927,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -6544,6 +7001,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from14To15(migrator, schema);
return 15;
case 15:
final schema = Schema16(database: database);
final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema);
return 16;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -6565,6 +7027,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -6581,5 +7044,6 @@ i1.OnUpgrade stepByStep({
from12To13: from12To13,
from13To14: from13To14,
from14To15: from14To15,
from15To16: from15To16,
),
);

View File

@ -24,6 +24,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
@ -197,6 +198,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
);
batch.insert(
@ -248,10 +251,21 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.batch((batch) {
for (final exif in data) {
int? width;
int? height;
if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) {
width = exif.exifImageHeight;
height = exif.exifImageWidth;
} else {
width = exif.exifImageWidth;
height = exif.exifImageHeight;
}
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
RemoteAssetEntityCompanion(width: Value(width), height: Value(height)),
where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(),
);
}
});

View File

@ -48,7 +48,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.checksum.equalsExp(_db.trashedLocalAssetEntity.checksum),
),
])..where(
_db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) &
_db.trashedLocalAssetEntity.source.equalsValue(TrashOrigin.remoteSync) &
_db.trashedLocalAssetEntity.albumId.isInQuery(selectedAlbumIds) &
_db.remoteAssetEntity.deletedAt.isNull(),
))
.get();
@ -84,6 +85,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
durationInSeconds: Value(item.asset.durationInSeconds),
isFavorite: Value(item.asset.isFavorite),
orientation: Value(item.asset.orientation),
source: TrashOrigin.localSync,
);
batch.insert<$TrashedLocalAssetEntityTable, TrashedLocalAssetEntityData>(
@ -124,7 +126,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
Future<void> trashLocalAsset(Map<String, List<LocalAsset>> assetsByAlbums) async {
if (assetsByAlbums.isEmpty) {
return;
return Future.value();
}
final companions = <TrashedLocalAssetEntityCompanion>[];
@ -147,6 +149,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
orientation: Value(asset.orientation),
createdAt: Value(asset.createdAt),
updatedAt: Value(asset.updatedAt),
source: const Value(TrashOrigin.remoteSync),
),
);
}
@ -165,7 +168,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
Future<void> applyRestoredAssets(List<String> idList) async {
if (idList.isEmpty) {
return;
return Future.value();
}
final trashedAssets = <TrashedLocalAssetEntityData>[];
@ -205,6 +208,58 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> applyTrashedAssets(List<String> idList) async {
if (idList.isEmpty) {
return Future.value();
}
final trashedAssets = <({LocalAssetEntityData asset, String albumId})>[];
for (final slice in idList.slices(kDriftMaxChunk)) {
final rows = await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
])..where(_db.localAlbumAssetEntity.assetId.isIn(slice))).get();
final assetsWithAlbum = rows.map(
(row) =>
(albumId: row.readTable(_db.localAlbumAssetEntity).albumId, asset: row.readTable(_db.localAssetEntity)),
);
trashedAssets.addAll(assetsWithAlbum);
}
if (trashedAssets.isEmpty) {
return;
}
final companions = trashedAssets.map((e) {
return TrashedLocalAssetEntityCompanion.insert(
id: e.asset.id,
name: e.asset.name,
type: e.asset.type,
createdAt: Value(e.asset.createdAt),
updatedAt: Value(e.asset.updatedAt),
width: Value(e.asset.width),
height: Value(e.asset.height),
durationInSeconds: Value(e.asset.durationInSeconds),
checksum: Value(e.asset.checksum),
isFavorite: Value(e.asset.isFavorite),
orientation: Value(e.asset.orientation),
source: TrashOrigin.localUser,
albumId: e.albumId,
);
});
await _db.transaction(() async {
for (final companion in companions) {
await _db.into(_db.trashedLocalAssetEntity).insertOnConflictUpdate(companion);
}
for (final slice in idList.slices(kDriftMaxChunk)) {
await (_db.delete(_db.localAssetEntity)..where((t) => t.id.isIn(slice))).go();
}
});
}
Future<Map<String, List<LocalAsset>>> getToTrash() async {
final result = <String, List<LocalAsset>>{};

View File

@ -42,6 +42,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/licenses.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
@ -252,6 +253,13 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
themeMode: ref.watch(immichThemeModeProvider),
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
builder: (context, child) => ImmichTranslationProvider(
translations: ImmichTranslations(
submit: "submit".t(context: context),
password: "password".t(context: context),
),
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
),
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)],

View File

@ -370,6 +370,7 @@ class _MapWithMarker extends StatelessWidget {
? PositionedAssetMarkerIcon(
point: value.point,
assetRemoteId: value.marker.assetRemoteId,
assetThumbhash: '',
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
onTap: onMarkerTapped,
)

View File

@ -19,6 +19,17 @@ List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color)
return children;
}
class _ComponentTitle extends StatelessWidget {
final String title;
const _ComponentTitle(this.title);
@override
Widget build(BuildContext context) {
return Text(title, style: context.textTheme.titleLarge);
}
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@ -35,13 +46,51 @@ class ImmichUIShowcasePage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
const _ComponentTitle("IconButton"),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("CloseButton"),
..._showcaseBuilder(
(variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}),
),
const _ComponentTitle("TextButton"),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.filled,
color: ImmichColor.primary,
loading: true,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
),
ImmichTextButton(
labelText: "Text Button",
onPressed: () {},
variant: ImmichVariant.ghost,
color: ImmichColor.primary,
loading: true,
),
const _ComponentTitle("Form"),
ImmichForm(
onSubmit: () {},
child: const Column(
spacing: 10,
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),

View File

@ -37,7 +37,7 @@ class DriftCropImagePage extends HookWidget {
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
@ -79,13 +79,13 @@ class DriftCropImagePage extends HookWidget {
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateLeft(),
onPressed: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateRight(),
onPressed: () => cropController.rotateRight(),
),
],
),

View File

@ -611,6 +611,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
child: SizedBox(
width: ctx.width,
height: ctx.height,

View File

@ -64,11 +64,10 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard local assets
if (asset != null && asset is LocalAsset && asset.hasRemote) {
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@ -92,7 +91,12 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
ExifMap(
exifInfo: exifInfo!,
markerId: asset.id,
markerAssetThumbhash: asset.thumbHash,
onMapCreated: _onMapCreated,
),
const SizedBox(height: 16),
if (locationName != null)
Padding(

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
@ -93,7 +94,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview),
headers: headers,
cacheManager: cacheManager,
);

View File

@ -10,7 +10,7 @@ final localFilesManagerRepositoryProvider = Provider(
class LocalFilesManagerRepository {
LocalFilesManagerRepository(this._service);
final Logger _logger = Logger('SyncStreamService');
final Logger _logger = Logger('LocalFilesManagerRepo');
final LocalFilesManagerService _service;
Future<bool> moveToTrash(List<String> mediaUrls) async {
@ -38,8 +38,10 @@ class LocalFilesManagerRepository {
for (final asset in assets) {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
try {
await _service.restoreFromTrashById(asset.id, asset.type.index);
restoredIds.add(asset.id);
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
} catch (e) {
_logger.warning("Restoring failure: $e");
}

View File

@ -5,9 +5,13 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
@ -28,6 +32,7 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashedLocalAssetRepository),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
),
@ -39,6 +44,7 @@ class ActionService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
@ -48,6 +54,7 @@ class ActionService {
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashedLocalAssetRepository,
this._assetMediaRepository,
this._downloadRepository,
);
@ -82,11 +89,7 @@ class ActionService {
// Ask user if they want to delete local copies
if (localIds.isNotEmpty) {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
}
await _deleteLocalAssets(localIds);
}
}
@ -110,11 +113,7 @@ class ActionService {
await _remoteAssetRepository.trash(remoteIds);
if (localIds.isNotEmpty) {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
}
await _deleteLocalAssets(localIds);
}
}
@ -123,22 +122,12 @@ class ActionService {
await _remoteAssetRepository.delete(remoteIds);
if (localIds.isNotEmpty) {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
}
await _deleteLocalAssets(localIds);
}
}
Future<int> deleteLocal(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isNotEmpty) {
await _localAssetRepository.delete(deletedIds);
return deletedIds.length;
}
return 0;
return await _deleteLocalAssets(localIds);
}
Future<bool> editLocation(List<String> remoteIds, BuildContext context) async {
@ -242,4 +231,17 @@ class ActionService {
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {
return 0;
}
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
} else {
await _localAssetRepository.delete(deletedIds);
}
return deletedIds.length;
}
}

View File

@ -1,4 +1,3 @@
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@ -10,14 +9,18 @@ String getThumbnailUrl(final Asset asset, {AssetMediaSize type = AssetMediaSize.
}
String getThumbnailCacheKey(final Asset asset, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, asset.thumbhash!, type: type);
}
String getThumbnailCacheKeyForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
String getThumbnailCacheKeyForRemoteId(
final String id,
final String thumbhash, {
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
if (type == AssetMediaSize.thumbnail) {
return 'thumbnail-image-$id';
return 'thumbnail-image-$id-$thumbhash';
} else {
return '${id}_previewStage';
return '${id}_${thumbhash}_previewStage';
}
}
@ -32,26 +35,25 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return getThumbnailCacheKeyForRemoteId(album.thumbnail.value!.remoteId!, type: type);
return getThumbnailCacheKeyForRemoteId(
album.thumbnail.value!.remoteId!,
album.thumbnail.value!.thumbhash!,
type: type,
);
}
String getOriginalUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original';
String getOriginalUrlForRemoteId(final String id, {bool edited = true}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited';
}
String getImageCacheKey(final Asset asset) {
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = asset.id == noDbId;
return '${isFromDto ? asset.remoteId : asset.id}_fullStage';
String getThumbnailUrlForRemoteId(
final String id, {
AssetMediaSize type = AssetMediaSize.thumbnail,
bool edited = true,
}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
}
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
}
String getPreviewUrlForRemoteId(final String id) =>
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
}

View File

@ -74,7 +74,7 @@ class AssetLocation extends HookConsumerWidget {
],
),
asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash),
const SizedBox(height: 16),
getLocationName(),
Text(

View File

@ -10,10 +10,20 @@ import 'package:url_launcher/url_launcher.dart';
class ExifMap extends StatelessWidget {
final ExifInfo exifInfo;
// TODO: Pass in a BaseAsset instead of the ID and thumbhash when removing old timeline
// This is currently structured this way because of the old timeline implementation
// reusing this component
final String? markerId;
final String? markerAssetThumbhash;
final MapCreatedCallback? onMapCreated;
const ExifMap({super.key, required this.exifInfo, this.markerId = 'marker', this.onMapCreated});
const ExifMap({
super.key,
required this.exifInfo,
this.markerAssetThumbhash,
this.markerId = 'marker',
this.onMapCreated,
});
@override
Widget build(BuildContext context) {
@ -61,6 +71,7 @@ class ExifMap extends StatelessWidget {
width: constraints.maxWidth,
zoom: 12.0,
assetMarkerRemoteId: markerId,
assetThumbhash: markerAssetThumbhash,
onTap: (tapPosition, latLong) async {
Uri? uri = await createCoordinatesUri();

View File

@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@ -29,12 +30,7 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -45,16 +41,33 @@ class LoginForm extends HookConsumerWidget {
final log = Logger('LoginForm');
String? _validateUrl(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(url);
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
String? _validateEmail(String? email) {
if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
@ -96,7 +109,6 @@ class LoginForm extends HookConsumerWidget {
}
try {
isLoadingServer.value = true;
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
// Fetch and load server config and features
@ -120,7 +132,6 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} on HandshakeException {
ImmichToast.show(
context: context,
@ -130,7 +141,6 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} catch (e) {
ImmichToast.show(
context: context,
@ -140,10 +150,7 @@ class LoginForm extends HookConsumerWidget {
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
}
isLoadingServer.value = false;
}
useEffect(() {
@ -230,8 +237,6 @@ class LoginForm extends HookConsumerWidget {
login() async {
TextInput.finishAutofillContext();
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
@ -261,8 +266,6 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
}
@ -306,8 +309,6 @@ class LoginForm extends HookConsumerWidget {
codeChallenge,
);
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
} catch (error, stack) {
@ -319,7 +320,6 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
@ -338,7 +338,6 @@ class LoginForm extends HookConsumerWidget {
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
isLoading.value = false;
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
@ -364,9 +363,7 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
} finally {}
} else {
ImmichToast.show(
context: context,
@ -374,66 +371,10 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.info,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
return;
}
}
buildSelectServer() {
const buttonRadius = 25.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerAuthSettings,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(buttonRadius),
bottomLeft: Radius.circular(buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(buttonRadius),
bottomRight: Radius.circular(buttonRadius),
),
),
),
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoadingServer.value) const LoadingIcon(),
],
);
}
buildVersionCompatWarning() {
checkVersionMismatch();
@ -455,66 +396,102 @@ class LoginForm extends HookConsumerWidget {
);
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildVersionCompatWarning(),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
final serverSelectionOrLogin = serverEndpoint.value == null
? Padding(
padding: const EdgeInsets.only(top: ImmichSpacing.md),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
ImmichForm(
submitText: 'next'.t(context: context),
submitIcon: Icons.arrow_forward_rounded,
onSubmit: getServerAuthSettings,
child: ImmichTextInput(
controller: serverEndpointController,
label: 'login_form_endpoint_url'.t(context: context),
hintText: 'login_form_endpoint_hint'.t(context: context),
validator: _validateUrl,
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
),
ImmichTextButton(
labelText: 'settings'.t(context: context),
icon: Icons.settings,
variant: ImmichVariant.ghost,
onPressed: () => context.pushRoute(const SettingsRoute()),
),
],
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
],
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
)
: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
buildVersionCompatWarning(),
Padding(
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
child: Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
),
if (isPasswordLoginEnable.value)
ImmichForm(
submitText: 'login'.t(context: context),
submitIcon: Icons.login_rounded,
onSubmit: login,
child: Column(
spacing: ImmichSpacing.md,
children: [
ImmichTextInput(
controller: emailController,
label: 'email'.t(context: context),
hintText: 'login_form_email_hint'.t(context: context),
validator: _validateEmail,
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
onSubmit: (_, _) => passwordFocusNode.requestFocus(),
),
ImmichPasswordInput(
controller: passwordController,
focusNode: passwordFocusNode,
label: 'password'.t(context: context),
hintText: 'login_form_password_hint'.t(context: context),
keyboardAction: TextInputAction.go,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
],
],
),
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('back').tr(),
if (isOauthEnable.value)
ImmichForm(
submitText: oAuthButtonLabel.value,
submitIcon: Icons.pin_outlined,
onSubmit: oAuthLogin,
child: isPasswordLoginEnable.value
? Padding(
padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5),
)
: const SizedBox.shrink(),
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value)
Center(child: const Text('login_disabled').tr()),
ImmichTextButton(
labelText: 'back'.t(context: context),
icon: Icons.arrow_back,
variant: ImmichVariant.ghost,
onPressed: () => serverEndpoint.value = null,
),
],
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
);
return LayoutBuilder(
builder: (context, constraints) {

View File

@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class OAuthLoginButton extends ConsumerWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final Function() onPressed;
const OAuthLoginButton({
super.key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: context.primaryColor.withAlpha(230),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: onPressed,
icon: const Icon(Icons.pin_rounded),
label: Text(buttonLabel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
);
}
}

View File

@ -1,37 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class PasswordInput extends HookConsumerWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPasswordVisible = useState<bool>(false);
return TextFormField(
obscureText: !isPasswordVisible.value,
controller: controller,
decoration: InputDecoration(
labelText: 'password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
suffixIcon: IconButton(
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.go,
);
}
}

View File

@ -1,46 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/url_helper.dart';
class ServerEndpointInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Function()? onSubmit;
const ServerEndpointInput({super.key, required this.controller, required this.focusNode, this.onSubmit});
String? _validateInput(String? url) {
if (url == null || url.isEmpty) return null;
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
return 'login_form_err_invalid_url'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(),
errorMaxLines: 4,
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
focusNode: focusNode,
autofillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
autocorrect: false,
onFieldSubmitted: (_) => onSubmit?.call(),
textInputAction: TextInputAction.go,
),
);
}
}

View File

@ -19,6 +19,7 @@ class MapThumbnail extends HookConsumerWidget {
final Function(Point<double>, LatLng)? onTap;
final LatLng centre;
final String? assetMarkerRemoteId;
final String? assetThumbhash;
final bool showMarkerPin;
final double zoom;
final double height;
@ -35,6 +36,7 @@ class MapThumbnail extends HookConsumerWidget {
this.onTap,
this.zoom = 8,
this.assetMarkerRemoteId,
this.assetThumbhash,
this.showMarkerPin = false,
this.themeMode,
this.showAttribution = true,
@ -109,8 +111,13 @@ class MapThumbnail extends HookConsumerWidget {
),
ValueListenableBuilder(
valueListenable: position,
builder: (_, value, __) => value != null && assetMarkerRemoteId != null
? PositionedAssetMarkerIcon(size: height / 2, point: value, assetRemoteId: assetMarkerRemoteId!)
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
assetThumbhash: assetThumbhash!,
)
: const SizedBox.shrink(),
),
],

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final String assetThumbhash;
final double size;
final int durationInMilliseconds;
@ -18,6 +19,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
required this.assetThumbhash,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
@ -35,7 +37,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: _AssetMarkerIcon(id: assetRemoteId, key: Key(assetRemoteId)),
child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
@ -43,14 +45,15 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({required this.id, super.key});
const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(

View File

@ -102,7 +102,9 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
@ -110,6 +112,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
@ -346,6 +349,13 @@ Class | Method | HTTP request | Description
- [AssetCopyDto](doc//AssetCopyDto.md)
- [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md)
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
- [AssetEditAction](doc//AssetEditAction.md)
- [AssetEditActionCrop](doc//AssetEditActionCrop.md)
- [AssetEditActionListDto](doc//AssetEditActionListDto.md)
- [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md)
- [AssetEditActionMirror](doc//AssetEditActionMirror.md)
- [AssetEditActionRotate](doc//AssetEditActionRotate.md)
- [AssetEditsDto](doc//AssetEditsDto.md)
- [AssetFaceCreateDto](doc//AssetFaceCreateDto.md)
- [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md)
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
@ -393,6 +403,7 @@ Class | Method | HTTP request | Description
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateLibraryDto](doc//CreateLibraryDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- [CropParameters](doc//CropParameters.md)
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
@ -437,6 +448,8 @@ Class | Method | HTTP request | Description
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
- [MirrorAxis](doc//MirrorAxis.md)
- [MirrorParameters](doc//MirrorParameters.md)
- [NotificationCreateDto](doc//NotificationCreateDto.md)
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
- [NotificationDto](doc//NotificationDto.md)
@ -497,6 +510,7 @@ Class | Method | HTTP request | Description
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
- [SearchExploreItem](doc//SearchExploreItem.md)

View File

@ -95,6 +95,13 @@ part 'model/asset_bulk_upload_check_result.dart';
part 'model/asset_copy_dto.dart';
part 'model/asset_delta_sync_dto.dart';
part 'model/asset_delta_sync_response_dto.dart';
part 'model/asset_edit_action.dart';
part 'model/asset_edit_action_crop.dart';
part 'model/asset_edit_action_list_dto.dart';
part 'model/asset_edit_action_list_dto_edits_inner.dart';
part 'model/asset_edit_action_mirror.dart';
part 'model/asset_edit_action_rotate.dart';
part 'model/asset_edits_dto.dart';
part 'model/asset_face_create_dto.dart';
part 'model/asset_face_delete_dto.dart';
part 'model/asset_face_response_dto.dart';
@ -142,6 +149,7 @@ part 'model/contributor_count_response_dto.dart';
part 'model/create_album_dto.dart';
part 'model/create_library_dto.dart';
part 'model/create_profile_image_response_dto.dart';
part 'model/crop_parameters.dart';
part 'model/database_backup_config.dart';
part 'model/download_archive_info.dart';
part 'model/download_info_dto.dart';
@ -186,6 +194,8 @@ part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
part 'model/mirror_axis.dart';
part 'model/mirror_parameters.dart';
part 'model/notification_create_dto.dart';
part 'model/notification_delete_all_dto.dart';
part 'model/notification_dto.dart';
@ -246,6 +256,7 @@ part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart';
part 'model/search_asset_response_dto.dart';
part 'model/search_explore_item.dart';

View File

@ -336,10 +336,12 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> downloadAssetWithHttpInfo(String id, { String? key, String? slug, }) async {
Future<Response> downloadAssetWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
@ -351,6 +353,9 @@ class AssetsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (edited != null) {
queryParams.addAll(_queryParams('', 'edited', edited));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
@ -380,11 +385,13 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> downloadAsset(String id, { String? key, String? slug, }) async {
final response = await downloadAssetWithHttpInfo(id, key: key, slug: slug, );
Future<MultipartFile?> downloadAsset(String id, { bool? edited, String? key, String? slug, }) async {
final response = await downloadAssetWithHttpInfo(id, edited: edited, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -398,6 +405,67 @@ class AssetsApi {
return null;
}
/// Apply edits to an existing asset
///
/// Apply a series of edit actions (crop, rotate, mirror) to the specified asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetEditActionListDto] assetEditActionListDto (required):
Future<Response> editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = assetEditActionListDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Apply edits to an existing asset
///
/// Apply a series of edit actions (crop, rotate, mirror) to the specified asset.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetEditActionListDto] assetEditActionListDto (required):
Future<AssetEditsDto?> editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async {
final response = await editAssetWithHttpInfo(id, assetEditActionListDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
}
return null;
}
/// Retrieve assets by device ID
///
/// Get all asset of a device that are in the database, ID only.
@ -458,6 +526,63 @@ class AssetsApi {
return null;
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getAssetEditsWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Parameters:
///
/// * [String] id (required):
Future<AssetEditsDto?> getAssetEdits(String id,) async {
final response = await getAssetEditsWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
}
return null;
}
/// Retrieve an asset
///
/// Retrieve detailed information about a specific asset.
@ -921,6 +1046,55 @@ class AssetsApi {
return null;
}
/// Remove edits from an existing asset
///
/// Removes all edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> removeAssetEditsWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/edits'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Remove edits from an existing asset
///
/// Removes all edit actions (crop, rotate, mirror) associated with the specified asset.
///
/// Parameters:
///
/// * [String] id (required):
Future<void> removeAssetEdits(String id,) async {
final response = await removeAssetEditsWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
@ -1525,12 +1699,14 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [AssetMediaSize] size:
///
/// * [String] slug:
Future<Response> viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, String? slug, }) async {
Future<Response> viewAssetWithHttpInfo(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/thumbnail'
.replaceAll('{id}', id);
@ -1542,6 +1718,9 @@ class AssetsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (edited != null) {
queryParams.addAll(_queryParams('', 'edited', edited));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
@ -1574,13 +1753,15 @@ class AssetsApi {
///
/// * [String] id (required):
///
/// * [bool] edited:
///
/// * [String] key:
///
/// * [AssetMediaSize] size:
///
/// * [String] slug:
Future<MultipartFile?> viewAsset(String id, { String? key, AssetMediaSize? size, String? slug, }) async {
final response = await viewAssetWithHttpInfo(id, key: key, size: size, slug: slug, );
Future<MultipartFile?> viewAsset(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async {
final response = await viewAssetWithHttpInfo(id, edited: edited, key: key, size: size, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@ -238,6 +238,20 @@ class ApiClient {
return AssetDeltaSyncDto.fromJson(value);
case 'AssetDeltaSyncResponseDto':
return AssetDeltaSyncResponseDto.fromJson(value);
case 'AssetEditAction':
return AssetEditActionTypeTransformer().decode(value);
case 'AssetEditActionCrop':
return AssetEditActionCrop.fromJson(value);
case 'AssetEditActionListDto':
return AssetEditActionListDto.fromJson(value);
case 'AssetEditActionListDtoEditsInner':
return AssetEditActionListDtoEditsInner.fromJson(value);
case 'AssetEditActionMirror':
return AssetEditActionMirror.fromJson(value);
case 'AssetEditActionRotate':
return AssetEditActionRotate.fromJson(value);
case 'AssetEditsDto':
return AssetEditsDto.fromJson(value);
case 'AssetFaceCreateDto':
return AssetFaceCreateDto.fromJson(value);
case 'AssetFaceDeleteDto':
@ -332,6 +346,8 @@ class ApiClient {
return CreateLibraryDto.fromJson(value);
case 'CreateProfileImageResponseDto':
return CreateProfileImageResponseDto.fromJson(value);
case 'CropParameters':
return CropParameters.fromJson(value);
case 'DatabaseBackupConfig':
return DatabaseBackupConfig.fromJson(value);
case 'DownloadArchiveInfo':
@ -420,6 +436,10 @@ class ApiClient {
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
case 'MirrorAxis':
return MirrorAxisTypeTransformer().decode(value);
case 'MirrorParameters':
return MirrorParameters.fromJson(value);
case 'NotificationCreateDto':
return NotificationCreateDto.fromJson(value);
case 'NotificationDeleteAllDto':
@ -540,6 +560,8 @@ class ApiClient {
return ReactionTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters':
return RotateParameters.fromJson(value);
case 'SearchAlbumResponseDto':
return SearchAlbumResponseDto.fromJson(value);
case 'SearchAssetResponseDto':

View File

@ -58,6 +58,9 @@ String parameterToString(dynamic value) {
if (value is AlbumUserRole) {
return AlbumUserRoleTypeTransformer().encode(value).toString();
}
if (value is AssetEditAction) {
return AssetEditActionTypeTransformer().encode(value).toString();
}
if (value is AssetJobName) {
return AssetJobNameTypeTransformer().encode(value).toString();
}
@ -109,6 +112,9 @@ String parameterToString(dynamic value) {
if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString();
}
if (value is MirrorAxis) {
return MirrorAxisTypeTransformer().encode(value).toString();
}
if (value is NotificationLevel) {
return NotificationLevelTypeTransformer().encode(value).toString();
}

View File

@ -0,0 +1,88 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditAction {
/// Instantiate a new enum with the provided [value].
const AssetEditAction._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const crop = AssetEditAction._(r'crop');
static const rotate = AssetEditAction._(r'rotate');
static const mirror = AssetEditAction._(r'mirror');
/// List of all possible values in this [enum][AssetEditAction].
static const values = <AssetEditAction>[
crop,
rotate,
mirror,
];
static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value);
static List<AssetEditAction> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditAction>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditAction.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetEditAction] to String,
/// and [decode] dynamic data back to [AssetEditAction].
class AssetEditActionTypeTransformer {
factory AssetEditActionTypeTransformer() => _instance ??= const AssetEditActionTypeTransformer._();
const AssetEditActionTypeTransformer._();
String encode(AssetEditAction data) => data.value;
/// Decodes a [dynamic value][data] to a AssetEditAction.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetEditAction? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'crop': return AssetEditAction.crop;
case r'rotate': return AssetEditAction.rotate;
case r'mirror': return AssetEditAction.mirror;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetEditActionTypeTransformer] instance.
static AssetEditActionTypeTransformer? _instance;
}

View File

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionCrop {
/// Returns a new [AssetEditActionCrop] instance.
AssetEditActionCrop({
required this.action,
required this.parameters,
});
AssetEditAction action;
CropParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionCrop &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionCrop[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionCrop] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionCrop? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionCrop");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionCrop(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: CropParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionCrop> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionCrop>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionCrop.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionCrop> mapFromJson(dynamic json) {
final map = <String, AssetEditActionCrop>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionCrop.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionCrop-objects as value to a dart map
static Map<String, List<AssetEditActionCrop>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionCrop>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionCrop.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionListDto {
/// Returns a new [AssetEditActionListDto] instance.
AssetEditActionListDto({
this.edits = const [],
});
/// list of edits
List<AssetEditActionListDtoEditsInner> edits;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDto &&
_deepEquality.equals(other.edits, edits);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(edits.hashCode);
@override
String toString() => 'AssetEditActionListDto[edits=$edits]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'edits'] = this.edits;
return json;
}
/// Returns a new [AssetEditActionListDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionListDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionListDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionListDto(
edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']),
);
}
return null;
}
static List<AssetEditActionListDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionListDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionListDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionListDto> mapFromJson(dynamic json) {
final map = <String, AssetEditActionListDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionListDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionListDto-objects as value to a dart map
static Map<String, List<AssetEditActionListDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionListDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionListDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'edits',
};
}

View File

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionListDtoEditsInner {
/// Returns a new [AssetEditActionListDtoEditsInner] instance.
AssetEditActionListDtoEditsInner({
required this.action,
required this.parameters,
});
AssetEditAction action;
MirrorParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionListDtoEditsInner[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionListDtoEditsInner] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionListDtoEditsInner? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionListDtoEditsInner");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionListDtoEditsInner(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionListDtoEditsInner> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionListDtoEditsInner>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionListDtoEditsInner.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionListDtoEditsInner> mapFromJson(dynamic json) {
final map = <String, AssetEditActionListDtoEditsInner>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionListDtoEditsInner.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionListDtoEditsInner-objects as value to a dart map
static Map<String, List<AssetEditActionListDtoEditsInner>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionListDtoEditsInner>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionListDtoEditsInner.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionMirror {
/// Returns a new [AssetEditActionMirror] instance.
AssetEditActionMirror({
required this.action,
required this.parameters,
});
AssetEditAction action;
MirrorParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionMirror &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionMirror[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionMirror] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionMirror? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionMirror");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionMirror(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionMirror> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionMirror>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionMirror.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionMirror> mapFromJson(dynamic json) {
final map = <String, AssetEditActionMirror>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionMirror.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionMirror-objects as value to a dart map
static Map<String, List<AssetEditActionMirror>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionMirror>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionMirror.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionRotate {
/// Returns a new [AssetEditActionRotate] instance.
AssetEditActionRotate({
required this.action,
required this.parameters,
});
AssetEditAction action;
RotateParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionRotate &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionRotate[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionRotate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionRotate? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionRotate");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionRotate(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: RotateParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionRotate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionRotate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionRotate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionRotate> mapFromJson(dynamic json) {
final map = <String, AssetEditActionRotate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionRotate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionRotate-objects as value to a dart map
static Map<String, List<AssetEditActionRotate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionRotate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionRotate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@ -0,0 +1,108 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditsDto {
/// Returns a new [AssetEditsDto] instance.
AssetEditsDto({
required this.assetId,
this.edits = const [],
});
String assetId;
/// list of edits
List<AssetEditActionListDtoEditsInner> edits;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto &&
other.assetId == assetId &&
_deepEquality.equals(other.edits, edits);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(edits.hashCode);
@override
String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'edits'] = this.edits;
return json;
}
/// Returns a new [AssetEditsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditsDto? fromJson(dynamic value) {
upgradeDto(value, "AssetEditsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditsDto(
assetId: mapValueOfType<String>(json, r'assetId')!,
edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']),
);
}
return null;
}
static List<AssetEditsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditsDto> mapFromJson(dynamic json) {
final map = <String, AssetEditsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditsDto-objects as value to a dart map
static Map<String, List<AssetEditsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'edits',
};
}

View File

@ -23,6 +23,7 @@ class AssetResponseDto {
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.hasMetadata,
required this.height,
required this.id,
required this.isArchived,
required this.isFavorite,
@ -45,6 +46,7 @@ class AssetResponseDto {
this.unassignedFaces = const [],
required this.updatedAt,
required this.visibility,
required this.width,
});
/// base64 encoded sha1 hash
@ -77,6 +79,8 @@ class AssetResponseDto {
bool hasMetadata;
num? height;
String id;
bool isArchived;
@ -141,6 +145,8 @@ class AssetResponseDto {
AssetVisibility visibility;
num? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.checksum == checksum &&
@ -153,6 +159,7 @@ class AssetResponseDto {
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.hasMetadata == hasMetadata &&
other.height == height &&
other.id == id &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
@ -174,7 +181,8 @@ class AssetResponseDto {
other.type == type &&
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
other.updatedAt == updatedAt &&
other.visibility == visibility;
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
@ -189,6 +197,7 @@ class AssetResponseDto {
(fileCreatedAt.hashCode) +
(fileModifiedAt.hashCode) +
(hasMetadata.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isArchived.hashCode) +
(isFavorite.hashCode) +
@ -210,10 +219,11 @@ class AssetResponseDto {
(type.hashCode) +
(unassignedFaces.hashCode) +
(updatedAt.hashCode) +
(visibility.hashCode);
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -235,6 +245,11 @@ class AssetResponseDto {
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
json[r'hasMetadata'] = this.hasMetadata;
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isArchived'] = this.isArchived;
json[r'isFavorite'] = this.isFavorite;
@ -285,6 +300,11 @@ class AssetResponseDto {
json[r'unassignedFaces'] = this.unassignedFaces;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@ -307,6 +327,9 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
@ -329,6 +352,9 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
);
}
return null;
@ -384,6 +410,7 @@ class AssetResponseDto {
'fileCreatedAt',
'fileModifiedAt',
'hasMetadata',
'height',
'id',
'isArchived',
'isFavorite',
@ -397,6 +424,7 @@ class AssetResponseDto {
'type',
'updatedAt',
'visibility',
'width',
};
}

View File

@ -0,0 +1,135 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class CropParameters {
/// Returns a new [CropParameters] instance.
CropParameters({
required this.height,
required this.width,
required this.x,
required this.y,
});
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
@override
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
other.height == height &&
other.width == width &&
other.x == x &&
other.y == y;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(height.hashCode) +
(width.hashCode) +
(x.hashCode) +
(y.hashCode);
@override
String toString() => 'CropParameters[height=$height, width=$width, x=$x, y=$y]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'height'] = this.height;
json[r'width'] = this.width;
json[r'x'] = this.x;
json[r'y'] = this.y;
return json;
}
/// Returns a new [CropParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static CropParameters? fromJson(dynamic value) {
upgradeDto(value, "CropParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return CropParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
);
}
return null;
}
static List<CropParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <CropParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = CropParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, CropParameters> mapFromJson(dynamic json) {
final map = <String, CropParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = CropParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of CropParameters-objects as value to a dart map
static Map<String, List<CropParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<CropParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = CropParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'height',
'width',
'x',
'y',
};
}

View File

@ -29,6 +29,7 @@ class JobName {
static const assetDetectFaces = JobName._(r'AssetDetectFaces');
static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll');
static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates');
static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration');
static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll');
static const assetEncodeVideo = JobName._(r'AssetEncodeVideo');
static const assetEmptyTrash = JobName._(r'AssetEmptyTrash');
@ -87,6 +88,7 @@ class JobName {
assetDetectFaces,
assetDetectDuplicatesQueueAll,
assetDetectDuplicates,
assetEditThumbnailGeneration,
assetEncodeVideoQueueAll,
assetEncodeVideo,
assetEmptyTrash,
@ -180,6 +182,7 @@ class JobNameTypeTransformer {
case r'AssetDetectFaces': return JobName.assetDetectFaces;
case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll;
case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates;
case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration;
case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll;
case r'AssetEncodeVideo': return JobName.assetEncodeVideo;
case r'AssetEmptyTrash': return JobName.assetEmptyTrash;

View File

@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Axis to mirror along
class MirrorAxis {
/// Instantiate a new enum with the provided [value].
const MirrorAxis._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const horizontal = MirrorAxis._(r'horizontal');
static const vertical = MirrorAxis._(r'vertical');
/// List of all possible values in this [enum][MirrorAxis].
static const values = <MirrorAxis>[
horizontal,
vertical,
];
static MirrorAxis? fromJson(dynamic value) => MirrorAxisTypeTransformer().decode(value);
static List<MirrorAxis> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MirrorAxis>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MirrorAxis.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MirrorAxis] to String,
/// and [decode] dynamic data back to [MirrorAxis].
class MirrorAxisTypeTransformer {
factory MirrorAxisTypeTransformer() => _instance ??= const MirrorAxisTypeTransformer._();
const MirrorAxisTypeTransformer._();
String encode(MirrorAxis data) => data.value;
/// Decodes a [dynamic value][data] to a MirrorAxis.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
MirrorAxis? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'horizontal': return MirrorAxis.horizontal;
case r'vertical': return MirrorAxis.vertical;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MirrorAxisTypeTransformer] instance.
static MirrorAxisTypeTransformer? _instance;
}

View File

@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MirrorParameters {
/// Returns a new [MirrorParameters] instance.
MirrorParameters({
required this.axis,
});
/// Axis to mirror along
MirrorAxis axis;
@override
bool operator ==(Object other) => identical(this, other) || other is MirrorParameters &&
other.axis == axis;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(axis.hashCode);
@override
String toString() => 'MirrorParameters[axis=$axis]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'axis'] = this.axis;
return json;
}
/// Returns a new [MirrorParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MirrorParameters? fromJson(dynamic value) {
upgradeDto(value, "MirrorParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MirrorParameters(
axis: MirrorAxis.fromJson(json[r'axis'])!,
);
}
return null;
}
static List<MirrorParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MirrorParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MirrorParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MirrorParameters> mapFromJson(dynamic json) {
final map = <String, MirrorParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MirrorParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MirrorParameters-objects as value to a dart map
static Map<String, List<MirrorParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MirrorParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MirrorParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'axis',
};
}

View File

@ -43,6 +43,10 @@ class Permission {
static const assetPeriodUpload = Permission._(r'asset.upload');
static const assetPeriodReplace = Permission._(r'asset.replace');
static const assetPeriodCopy = Permission._(r'asset.copy');
static const assetPeriodDerive = Permission._(r'asset.derive');
static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get');
static const assetPeriodEditPeriodCreate = Permission._(r'asset.edit.create');
static const assetPeriodEditPeriodDelete = Permission._(r'asset.edit.delete');
static const albumPeriodCreate = Permission._(r'album.create');
static const albumPeriodRead = Permission._(r'album.read');
static const albumPeriodUpdate = Permission._(r'album.update');
@ -191,6 +195,10 @@ class Permission {
assetPeriodUpload,
assetPeriodReplace,
assetPeriodCopy,
assetPeriodDerive,
assetPeriodEditPeriodGet,
assetPeriodEditPeriodCreate,
assetPeriodEditPeriodDelete,
albumPeriodCreate,
albumPeriodRead,
albumPeriodUpdate,
@ -374,6 +382,10 @@ class PermissionTypeTransformer {
case r'asset.upload': return Permission.assetPeriodUpload;
case r'asset.replace': return Permission.assetPeriodReplace;
case r'asset.copy': return Permission.assetPeriodCopy;
case r'asset.derive': return Permission.assetPeriodDerive;
case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet;
case r'asset.edit.create': return Permission.assetPeriodEditPeriodCreate;
case r'asset.edit.delete': return Permission.assetPeriodEditPeriodDelete;
case r'album.create': return Permission.albumPeriodCreate;
case r'album.read': return Permission.albumPeriodRead;
case r'album.update': return Permission.albumPeriodUpdate;

View File

@ -40,6 +40,7 @@ class QueueName {
static const backupDatabase = QueueName._(r'backupDatabase');
static const ocr = QueueName._(r'ocr');
static const workflow = QueueName._(r'workflow');
static const editor = QueueName._(r'editor');
/// List of all possible values in this [enum][QueueName].
static const values = <QueueName>[
@ -60,6 +61,7 @@ class QueueName {
backupDatabase,
ocr,
workflow,
editor,
];
static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value);
@ -115,6 +117,7 @@ class QueueNameTypeTransformer {
case r'backupDatabase': return QueueName.backupDatabase;
case r'ocr': return QueueName.ocr;
case r'workflow': return QueueName.workflow;
case r'editor': return QueueName.editor;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -16,6 +16,7 @@ class QueuesResponseLegacyDto {
required this.backgroundTask,
required this.backupDatabase,
required this.duplicateDetection,
required this.editor,
required this.faceDetection,
required this.facialRecognition,
required this.library_,
@ -38,6 +39,8 @@ class QueuesResponseLegacyDto {
QueueResponseLegacyDto duplicateDetection;
QueueResponseLegacyDto editor;
QueueResponseLegacyDto faceDetection;
QueueResponseLegacyDto facialRecognition;
@ -71,6 +74,7 @@ class QueuesResponseLegacyDto {
other.backgroundTask == backgroundTask &&
other.backupDatabase == backupDatabase &&
other.duplicateDetection == duplicateDetection &&
other.editor == editor &&
other.faceDetection == faceDetection &&
other.facialRecognition == facialRecognition &&
other.library_ == library_ &&
@ -92,6 +96,7 @@ class QueuesResponseLegacyDto {
(backgroundTask.hashCode) +
(backupDatabase.hashCode) +
(duplicateDetection.hashCode) +
(editor.hashCode) +
(faceDetection.hashCode) +
(facialRecognition.hashCode) +
(library_.hashCode) +
@ -108,13 +113,14 @@ class QueuesResponseLegacyDto {
(workflow.hashCode);
@override
String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, editor=$editor, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'backgroundTask'] = this.backgroundTask;
json[r'backupDatabase'] = this.backupDatabase;
json[r'duplicateDetection'] = this.duplicateDetection;
json[r'editor'] = this.editor;
json[r'faceDetection'] = this.faceDetection;
json[r'facialRecognition'] = this.facialRecognition;
json[r'library'] = this.library_;
@ -144,6 +150,7 @@ class QueuesResponseLegacyDto {
backgroundTask: QueueResponseLegacyDto.fromJson(json[r'backgroundTask'])!,
backupDatabase: QueueResponseLegacyDto.fromJson(json[r'backupDatabase'])!,
duplicateDetection: QueueResponseLegacyDto.fromJson(json[r'duplicateDetection'])!,
editor: QueueResponseLegacyDto.fromJson(json[r'editor'])!,
faceDetection: QueueResponseLegacyDto.fromJson(json[r'faceDetection'])!,
facialRecognition: QueueResponseLegacyDto.fromJson(json[r'facialRecognition'])!,
library_: QueueResponseLegacyDto.fromJson(json[r'library'])!,
@ -208,6 +215,7 @@ class QueuesResponseLegacyDto {
'backgroundTask',
'backupDatabase',
'duplicateDetection',
'editor',
'faceDetection',
'facialRecognition',
'library',

View File

@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class RotateParameters {
/// Returns a new [RotateParameters] instance.
RotateParameters({
required this.angle,
});
/// Rotation angle in degrees
num angle;
@override
bool operator ==(Object other) => identical(this, other) || other is RotateParameters &&
other.angle == angle;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(angle.hashCode);
@override
String toString() => 'RotateParameters[angle=$angle]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'angle'] = this.angle;
return json;
}
/// Returns a new [RotateParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RotateParameters? fromJson(dynamic value) {
upgradeDto(value, "RotateParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return RotateParameters(
angle: num.parse('${json[r'angle']}'),
);
}
return null;
}
static List<RotateParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RotateParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RotateParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RotateParameters> mapFromJson(dynamic json) {
final map = <String, RotateParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RotateParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RotateParameters-objects as value to a dart map
static Map<String, List<RotateParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RotateParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RotateParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'angle',
};
}

View File

@ -18,6 +18,7 @@ class SyncAssetV1 {
required this.duration,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.height,
required this.id,
required this.isFavorite,
required this.libraryId,
@ -29,6 +30,7 @@ class SyncAssetV1 {
required this.thumbhash,
required this.type,
required this.visibility,
required this.width,
});
String checksum;
@ -41,6 +43,8 @@ class SyncAssetV1 {
DateTime? fileModifiedAt;
int? height;
String id;
bool isFavorite;
@ -63,6 +67,8 @@ class SyncAssetV1 {
AssetVisibility visibility;
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
other.checksum == checksum &&
@ -70,6 +76,7 @@ class SyncAssetV1 {
other.duration == duration &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.height == height &&
other.id == id &&
other.isFavorite == isFavorite &&
other.libraryId == libraryId &&
@ -80,7 +87,8 @@ class SyncAssetV1 {
other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility;
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
@ -90,6 +98,7 @@ class SyncAssetV1 {
(duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
@ -100,10 +109,11 @@ class SyncAssetV1 {
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode);
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -127,6 +137,11 @@ class SyncAssetV1 {
json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String();
} else {
// json[r'fileModifiedAt'] = null;
}
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite;
@ -159,6 +174,11 @@ class SyncAssetV1 {
}
json[r'type'] = this.type;
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@ -176,6 +196,7 @@ class SyncAssetV1 {
duration: mapValueOfType<String>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'),
@ -187,6 +208,7 @@ class SyncAssetV1 {
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
@ -239,6 +261,7 @@ class SyncAssetV1 {
'duration',
'fileCreatedAt',
'fileModifiedAt',
'height',
'id',
'isFavorite',
'libraryId',
@ -250,6 +273,7 @@ class SyncAssetV1 {
'thumbhash',
'type',
'visibility',
'width',
};
}

View File

@ -14,6 +14,7 @@ class SystemConfigJobDto {
/// Returns a new [SystemConfigJobDto] instance.
SystemConfigJobDto({
required this.backgroundTask,
required this.editor,
required this.faceDetection,
required this.library_,
required this.metadataExtraction,
@ -30,6 +31,8 @@ class SystemConfigJobDto {
JobSettingsDto backgroundTask;
JobSettingsDto editor;
JobSettingsDto faceDetection;
JobSettingsDto library_;
@ -57,6 +60,7 @@ class SystemConfigJobDto {
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto &&
other.backgroundTask == backgroundTask &&
other.editor == editor &&
other.faceDetection == faceDetection &&
other.library_ == library_ &&
other.metadataExtraction == metadataExtraction &&
@ -74,6 +78,7 @@ class SystemConfigJobDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(backgroundTask.hashCode) +
(editor.hashCode) +
(faceDetection.hashCode) +
(library_.hashCode) +
(metadataExtraction.hashCode) +
@ -88,11 +93,12 @@ class SystemConfigJobDto {
(workflow.hashCode);
@override
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, editor=$editor, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'backgroundTask'] = this.backgroundTask;
json[r'editor'] = this.editor;
json[r'faceDetection'] = this.faceDetection;
json[r'library'] = this.library_;
json[r'metadataExtraction'] = this.metadataExtraction;
@ -118,6 +124,7 @@ class SystemConfigJobDto {
return SystemConfigJobDto(
backgroundTask: JobSettingsDto.fromJson(json[r'backgroundTask'])!,
editor: JobSettingsDto.fromJson(json[r'editor'])!,
faceDetection: JobSettingsDto.fromJson(json[r'faceDetection'])!,
library_: JobSettingsDto.fromJson(json[r'library'])!,
metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!,
@ -178,6 +185,7 @@ class SystemConfigJobDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'backgroundTask',
'editor',
'faceDetection',
'library',
'metadataExtraction',

View File

@ -1,3 +1,10 @@
export 'src/buttons/close_button.dart';
export 'src/buttons/icon_button.dart';
export 'src/components/close_button.dart';
export 'src/components/form.dart';
export 'src/components/icon_button.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
export 'src/constants.dart';
export 'src/theme.dart';
export 'src/translation.dart';
export 'src/types.dart';

View File

@ -1,15 +1,16 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/buttons/icon_button.dart';
import 'package:immich_ui/src/types.dart';
import 'icon_button.dart';
class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onTap;
final VoidCallback? onPressed;
final ImmichVariant variant;
final ImmichColor color;
const ImmichCloseButton({
super.key,
this.onTap,
this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.ghost,
});
@ -20,6 +21,6 @@ class ImmichCloseButton extends StatelessWidget {
icon: Icons.close,
color: color,
variant: variant,
onTap: onTap ?? () => Navigator.of(context).pop(),
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}

View File

@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichForm extends StatefulWidget {
final String? submitText;
final IconData? submitIcon;
final FutureOr<void> Function()? onSubmit;
final Widget child;
const ImmichForm({
super.key,
this.submitText,
this.submitIcon,
required this.onSubmit,
required this.child,
});
@override
State<ImmichForm> createState() => ImmichFormState();
static ImmichFormState of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<_ImmichFormScope>();
if (scope == null) {
throw FlutterError(
'ImmichForm.of() called with a context that does not contain an ImmichForm.\n'
'No ImmichForm ancestor could be found starting from the context that was passed to '
'ImmichForm.of(). This usually happens when the context provided is '
'from a widget above the ImmichForm.\n'
'The context used was:\n'
'$context',
);
}
return scope._formState;
}
}
class ImmichFormState extends State<ImmichForm> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
FutureOr<void> submit() async {
final isValid = _formKey.currentState?.validate() ?? false;
if (!isValid) {
return;
}
setState(() {
_isLoading = true;
});
try {
await widget.onSubmit?.call();
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final submitText = widget.submitText ?? context.translations.submit;
return _ImmichFormScope(
formState: this,
child: Form(
key: _formKey,
child: Column(
spacing: ImmichSpacing.md,
children: [
widget.child,
ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
loading: _isLoading,
onPressed: submit,
disabled: widget.onSubmit == null,
),
],
),
),
);
}
}
class _ImmichFormScope extends InheritedWidget {
const _ImmichFormScope({required super.child, required ImmichFormState formState}) : _formState = formState;
final ImmichFormState _formState;
@override
bool updateShouldNotify(_ImmichFormScope oldWidget) => oldWidget._formState != _formState;
}

View File

@ -3,42 +3,48 @@ import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final VoidCallback onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
const ImmichIconButton({
super.key,
required this.icon,
required this.onTap,
required this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.disabled = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
return IconButton(
icon: Icon(icon),
onPressed: onTap,
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/text_input.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichPasswordInput extends StatefulWidget {
final String? label;
final String? hintText;
final TextEditingController? controller;
final FocusNode? focusNode;
final String? Function(String?)? validator;
final void Function(BuildContext, String)? onSubmit;
final TextInputAction? keyboardAction;
const ImmichPasswordInput({
super.key,
this.controller,
this.focusNode,
this.label,
this.hintText,
this.validator,
this.onSubmit,
this.keyboardAction,
});
@override
State createState() => _ImmichPasswordInputState();
}
class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
bool _visible = false;
void _toggleVisibility() {
setState(() {
_visible = !_visible;
});
}
@override
Widget build(BuildContext context) {
return ImmichTextInput(
key: widget.key,
label: widget.label ?? context.translations.password,
hintText: widget.hintText,
controller: widget.controller,
focusNode: widget.focusNode,
validator: widget.validator,
onSubmit: widget.onSubmit,
keyboardAction: widget.keyboardAction,
obscureText: !_visible,
suffixIcon: IconButton(
onPressed: _toggleVisibility,
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}

View File

@ -0,0 +1,87 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
class ImmichTextButton extends StatelessWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.expanded = true,
this.loading = false,
this.disabled = false,
});
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
}
}
@override
Widget build(BuildContext context) {
final button = _buildButton(variant);
if (expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
}
}

View File

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
class ImmichTextInput extends StatefulWidget {
final String label;
final String? hintText;
final TextEditingController? controller;
final FocusNode? focusNode;
final String? Function(String?)? validator;
final void Function(BuildContext, String)? onSubmit;
final TextInputType keyboardType;
final TextInputAction? keyboardAction;
final List<String>? autofillHints;
final Widget? suffixIcon;
final bool obscureText;
const ImmichTextInput({
super.key,
this.controller,
this.focusNode,
required this.label,
this.hintText,
this.validator,
this.onSubmit,
this.keyboardType = TextInputType.text,
this.keyboardAction,
this.autofillHints,
this.suffixIcon,
this.obscureText = false,
});
@override
State createState() => _ImmichTextInputState();
}
class _ImmichTextInputState extends State<ImmichTextInput> {
late final FocusNode _focusNode;
String? _error;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
}
@override
void dispose() {
if (widget.focusNode == null) {
_focusNode.dispose();
}
super.dispose();
}
String? _validateInput(String? value) {
setState(() {
_error = widget.validator?.call(value);
});
return null;
}
bool get _hasError => _error != null && _error!.isNotEmpty;
@override
Widget build(BuildContext context) {
final themeData = Theme.of(context);
return TextFormField(
controller: widget.controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: widget.hintText,
labelText: widget.label,
labelStyle: themeData.inputDecorationTheme.labelStyle?.copyWith(
color: _hasError ? themeData.colorScheme.error : null,
),
errorText: _error,
suffixIcon: widget.suffixIcon,
),
obscureText: widget.obscureText,
validator: _validateInput,
keyboardType: widget.keyboardType,
textInputAction: widget.keyboardAction,
autofillHints: widget.autofillHints,
onTap: () => setState(() => _error = null),
onTapOutside: (_) => _focusNode.unfocus(),
onFieldSubmitted: (value) => widget.onSubmit?.call(context, value),
);
}
}

View File

@ -0,0 +1,199 @@
/// Spacing constants for gaps between widgets
abstract class ImmichSpacing {
const ImmichSpacing._();
/// Extra small spacing: 4.0
static const double xs = 4.0;
/// Small spacing: 8.0
static const double sm = 8.0;
/// Medium spacing (default): 12.0
static const double md = 12.0;
/// Large spacing: 16.0
static const double lg = 16.0;
/// Extra large spacing: 24.0
static const double xl = 24.0;
/// Extra extra large spacing: 32.0
static const double xxl = 32.0;
/// Extra extra extra large spacing: 48.0
static const double xxxl = 48.0;
}
/// Border radius constants for consistent rounded corners
abstract class ImmichRadius {
const ImmichRadius._();
/// No radius: 0.0
static const double none = 0.0;
/// Extra small radius: 4.0
static const double xs = 4.0;
/// Small radius: 8.0
static const double sm = 8.0;
/// Medium radius (default): 12.0
static const double md = 12.0;
/// Large radius: 16.0
static const double lg = 16.0;
/// Extra large radius: 20.0
static const double xl = 20.0;
/// Extra extra large radius: 24.0
static const double xxl = 24.0;
/// Full circular radius: infinity
static const double full = double.infinity;
}
/// Icon size constants for consistent icon sizing
abstract class ImmichIconSize {
const ImmichIconSize._();
/// Extra small icon: 16.0
static const double xs = 16.0;
/// Small icon: 20.0
static const double sm = 20.0;
/// Medium icon (default): 24.0
static const double md = 24.0;
/// Large icon: 32.0
static const double lg = 32.0;
/// Extra large icon: 40.0
static const double xl = 40.0;
/// Extra extra large icon: 48.0
static const double xxl = 48.0;
}
/// Animation duration constants for consistent timing
abstract class ImmichDuration {
const ImmichDuration._();
/// Extra fast: 100ms
static const Duration extraFast = Duration(milliseconds: 100);
/// Fast: 150ms
static const Duration fast = Duration(milliseconds: 150);
/// Normal: 200ms
static const Duration normal = Duration(milliseconds: 200);
/// Moderate: 300ms
static const Duration moderate = Duration(milliseconds: 300);
/// Slow: 500ms
static const Duration slow = Duration(milliseconds: 500);
/// Extra slow: 700ms
static const Duration extraSlow = Duration(milliseconds: 700);
}
/// Elevation constants for consistent shadows and depth
abstract class ImmichElevation {
const ImmichElevation._();
/// No elevation: 0.0
static const double none = 0.0;
/// Extra small elevation: 1.0
static const double xs = 1.0;
/// Small elevation: 2.0
static const double sm = 2.0;
/// Medium elevation: 4.0
static const double md = 4.0;
/// Large elevation: 8.0
static const double lg = 8.0;
/// Extra large elevation: 12.0
static const double xl = 12.0;
/// Extra extra large elevation: 16.0
static const double xxl = 16.0;
}
/// Border width constants (similar to Tailwind's border-* scale)
abstract class ImmichBorderWidth {
const ImmichBorderWidth._();
/// No border: 0.0
static const double none = 0.0;
/// Hairline border: 0.5
static const double hairline = 0.5;
/// Default border: 1.0 (border)
static const double base = 1.0;
/// Medium border: 2.0 (border-2)
static const double md = 2.0;
/// Large border: 3.0 (border-4)
static const double lg = 3.0;
/// Extra large border: 4.0
static const double xl = 4.0;
}
/// Text size constants with semantic HTML-like naming
/// These follow a type scale for harmonious text hierarchy
abstract class ImmichTextSize {
const ImmichTextSize._();
/// Caption text: 10.0
/// Use for: Tiny labels, legal text, metadata, timestamps
static const double caption = 10.0;
/// Label text: 12.0
/// Use for: Form labels, secondary text, helper text
static const double label = 12.0;
/// Body text: 14.0 (default)
/// Use for: Main body text, paragraphs, default UI text
static const double body = 14.0;
/// Body emphasized: 16.0
/// Use for: Emphasized body text, button labels, tabs
static const double bodyLarge = 16.0;
/// Heading 6: 18.0 (smallest heading)
/// Use for: Subtitles, card titles, section headers
static const double h6 = 18.0;
/// Heading 5: 20.0
/// Use for: Small headings, prominent labels
static const double h5 = 20.0;
/// Heading 4: 24.0
/// Use for: Page titles, dialog titles
static const double h4 = 24.0;
/// Heading 3: 30.0
/// Use for: Section headings, large headings
static const double h3 = 30.0;
/// Heading 2: 36.0
/// Use for: Major section headings
static const double h2 = 36.0;
/// Heading 1: 48.0 (largest heading)
/// Use for: Page hero headings, main titles
static const double h1 = 48.0;
/// Display text: 60.0
/// Use for: Hero numbers, splash screens, extra large display
static const double display = 60.0;
}

View File

@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}

View File

@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
class ImmichThemeProvider extends StatelessWidget {
final ColorScheme colorScheme;
final Widget child;
const ImmichThemeProvider({super.key, required this.colorScheme, required this.child});
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: colorScheme,
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
floatingLabelBehavior: FloatingLabelBehavior.always,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
),
labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600),
hintStyle: const TextStyle(fontSize: ImmichTextSize.body),
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w600),
),
),
child: child,
);
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class ImmichTranslations {
late String submit;
late String password;
ImmichTranslations({String? submit, String? password}) {
this.submit = submit ?? 'Submit';
this.password = password ?? 'Password';
}
}
class ImmichTranslationProvider extends InheritedWidget {
final ImmichTranslations? translations;
const ImmichTranslationProvider({
super.key,
this.translations,
required super.child,
});
static ImmichTranslations of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<ImmichTranslationProvider>();
return provider?.translations ?? ImmichTranslations();
}
@override
bool updateShouldNotify(covariant ImmichTranslationProvider oldWidget) {
return oldWidget.translations != translations;
}
}

View File

@ -0,0 +1,185 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:openapi/api.dart';
SyncUserV1 _createUser({String id = 'user-1'}) {
return SyncUserV1(
id: id,
name: 'Test User',
email: 'test@test.com',
deletedAt: null,
avatarColor: null,
hasProfileImage: false,
profileChangedAt: DateTime(2024, 1, 1),
);
}
SyncAssetV1 _createAsset({
required String id,
required String checksum,
required String fileName,
String ownerId = 'user-1',
int? width,
int? height,
}) {
return SyncAssetV1(
id: id,
checksum: checksum,
originalFileName: fileName,
type: AssetTypeEnum.IMAGE,
ownerId: ownerId,
isFavorite: false,
fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1),
visibility: AssetVisibility.timeline,
width: width,
height: height,
deletedAt: null,
duration: null,
libraryId: null,
livePhotoVideoId: null,
stackId: null,
thumbhash: null,
);
}
SyncAssetExifV1 _createExif({
required String assetId,
required int width,
required int height,
required String orientation,
}) {
return SyncAssetExifV1(
assetId: assetId,
exifImageWidth: width,
exifImageHeight: height,
orientation: orientation,
city: null,
country: null,
dateTimeOriginal: null,
description: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: null,
model: null,
modifyDate: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
);
}
void main() {
late Drift db;
late SyncStreamRepository sut;
setUp(() async {
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
sut = SyncStreamRepository(db);
});
tearDown(() async {
await db.close();
});
group('SyncStreamRepository - Dimension swapping based on orientation', () {
test('swaps dimensions for asset with rotated orientation', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-$orientation',
fileName: 'rotated_$orientation.jpg',
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for 90 degrees CW
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1080));
expect(result.height, equals(1920));
}
});
test('does not swap dimensions for asset with normal orientation', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg');
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for normal
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1920));
expect(result.height, equals(1080));
}
});
test('does not update dimensions if asset already has width and height', () async {
const assetId = 'asset-with-dimensions';
const existingWidth = 1920;
const existingHeight = 1080;
const exifWidth = 3840;
const exifHeight = 2160;
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-with-dims',
fileName: 'with_dimensions.jpg',
width: existingWidth,
height: existingHeight,
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6');
await sut.updateAssetsExifV1([exif]);
// Verify the asset still has original dimensions (not updated from EXIF)
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set');
expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set');
});
});
}

View File

@ -166,8 +166,8 @@ void main() {
expect(result, 1080 / 1920);
});
test('handles various flipped EXIF orientations correctly', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
test('should not flip remote asset dimensions', () async {
final flippedOrientations = ['1', '2', '3', '4', '5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
@ -178,23 +178,7 @@ void main() {
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions');
}
});
test('handles various non-flipped EXIF orientations correctly', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions');
expect(result, 1920 / 1080, reason: 'Should not flipped remote asset dimensions for orientation $orientation');
}
});
});

View File

@ -157,7 +157,14 @@ void main() {
'album-a': [platformAsset],
});
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(any())).called(1);
final trashedSnapshot =
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>;
expect(trashedSnapshot.length, 1);
final trashedEntry = trashedSnapshot.single;
expect(trashedEntry.albumId, 'album-a');
expect(trashedEntry.asset.id, platformAsset.id);
expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
@ -178,6 +185,10 @@ void main() {
await sut.processTrashedAssets({});
final trashedSnapshot =
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>;
expect(trashedSnapshot, isEmpty);
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
});

View File

@ -18,6 +18,7 @@ import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -53,6 +54,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v14.DatabaseAtV14(db);
case 15:
return v15.DatabaseAtV15(db);
case 16:
return v16.DatabaseAtV16(db);
default:
throw MissingSchemaException(version, versions);
}
@ -74,5 +77,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
13,
14,
15,
16,
];
}

View File

@ -1486,13 +1486,6 @@ class LocalAssetEntity extends Table
requiredDuringInsert: false,
defaultValue: const CustomExpression('0'),
);
late final GeneratedColumn<String> iCloudId = GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
late final GeneratedColumn<DateTime> adjustmentTime =
GeneratedColumn<DateTime>(
'adjustment_time',
@ -1528,7 +1521,6 @@ class LocalAssetEntity extends Table
checksum,
isFavorite,
orientation,
iCloudId,
adjustmentTime,
latitude,
longitude,
@ -1588,10 +1580,6 @@ class LocalAssetEntity extends Table
DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
iCloudId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}i_cloud_id'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
@ -1631,7 +1619,6 @@ class LocalAssetEntityData extends DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final String? iCloudId;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
@ -1647,7 +1634,6 @@ class LocalAssetEntityData extends DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
this.iCloudId,
this.adjustmentTime,
this.latitude,
this.longitude,
@ -1674,9 +1660,6 @@ class LocalAssetEntityData extends DataClass
}
map['is_favorite'] = Variable<bool>(isFavorite);
map['orientation'] = Variable<int>(orientation);
if (!nullToAbsent || iCloudId != null) {
map['i_cloud_id'] = Variable<String>(iCloudId);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime);
}
@ -1706,7 +1689,6 @@ class LocalAssetEntityData extends DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
iCloudId: serializer.fromJson<String?>(json['iCloudId']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
@ -1727,7 +1709,6 @@ class LocalAssetEntityData extends DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'iCloudId': serializer.toJson<String?>(iCloudId),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
@ -1746,7 +1727,6 @@ class LocalAssetEntityData extends DataClass
Value<String?> checksum = const Value.absent(),
bool? isFavorite,
int? orientation,
Value<String?> iCloudId = const Value.absent(),
Value<DateTime?> adjustmentTime = const Value.absent(),
Value<double?> latitude = const Value.absent(),
Value<double?> longitude = const Value.absent(),
@ -1764,7 +1744,6 @@ class LocalAssetEntityData extends DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
@ -1790,7 +1769,6 @@ class LocalAssetEntityData extends DataClass
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
@ -1813,7 +1791,6 @@ class LocalAssetEntityData extends DataClass
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
@ -1834,7 +1811,6 @@ class LocalAssetEntityData extends DataClass
checksum,
isFavorite,
orientation,
iCloudId,
adjustmentTime,
latitude,
longitude,
@ -1854,7 +1830,6 @@ class LocalAssetEntityData extends DataClass
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation &&
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
@ -1872,7 +1847,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
final Value<String?> checksum;
final Value<bool> isFavorite;
final Value<int> orientation;
final Value<String?> iCloudId;
final Value<DateTime?> adjustmentTime;
final Value<double?> latitude;
final Value<double?> longitude;
@ -1888,7 +1862,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
this.iCloudId = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
@ -1905,7 +1878,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
this.iCloudId = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
@ -1924,7 +1896,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
Expression<String>? checksum,
Expression<bool>? isFavorite,
Expression<int>? orientation,
Expression<String>? iCloudId,
Expression<DateTime>? adjustmentTime,
Expression<double>? latitude,
Expression<double>? longitude,
@ -1941,7 +1912,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (iCloudId != null) 'i_cloud_id': iCloudId,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
@ -1960,7 +1930,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
Value<String?>? checksum,
Value<bool>? isFavorite,
Value<int>? orientation,
Value<String?>? iCloudId,
Value<DateTime?>? adjustmentTime,
Value<double?>? latitude,
Value<double?>? longitude,
@ -1977,7 +1946,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
iCloudId: iCloudId ?? this.iCloudId,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
@ -2020,9 +1988,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
if (orientation.present) {
map['orientation'] = Variable<int>(orientation.value);
}
if (iCloudId.present) {
map['i_cloud_id'] = Variable<String>(iCloudId.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime.value);
}
@ -2049,7 +2014,6 @@ class LocalAssetEntityCompanion extends UpdateCompanion<LocalAssetEntityData> {
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
@ -5369,349 +5333,6 @@ class RemoteAlbumUserEntityCompanion
}
}
class RemoteAssetCloudIdEntity extends Table
with TableInfo<RemoteAssetCloudIdEntity, RemoteAssetCloudIdEntityData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
RemoteAssetCloudIdEntity(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> assetId = GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
late final GeneratedColumn<String> cloudId = GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'),
);
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
true,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
);
late final GeneratedColumn<DateTime> adjustmentTime =
GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
);
late final GeneratedColumn<double> latitude = GeneratedColumn<double>(
'latitude',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
late final GeneratedColumn<double> longitude = GeneratedColumn<double>(
'longitude',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_asset_cloud_id_entity';
@override
Set<GeneratedColumn> get $primaryKey => {assetId};
@override
RemoteAssetCloudIdEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return RemoteAssetCloudIdEntityData(
assetId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
cloudId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}cloud_id'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
),
latitude: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}latitude'],
),
longitude: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
);
}
@override
RemoteAssetCloudIdEntity createAlias(String alias) {
return RemoteAssetCloudIdEntity(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAssetCloudIdEntityData extends DataClass
implements Insertable<RemoteAssetCloudIdEntityData> {
final String assetId;
final String? cloudId;
final DateTime? createdAt;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const RemoteAssetCloudIdEntityData({
required this.assetId,
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['asset_id'] = Variable<String>(assetId);
if (!nullToAbsent || cloudId != null) {
map['cloud_id'] = Variable<String>(cloudId);
}
if (!nullToAbsent || createdAt != null) {
map['created_at'] = Variable<DateTime>(createdAt);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime);
}
if (!nullToAbsent || latitude != null) {
map['latitude'] = Variable<double>(latitude);
}
if (!nullToAbsent || longitude != null) {
map['longitude'] = Variable<double>(longitude);
}
return map;
}
factory RemoteAssetCloudIdEntityData.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return RemoteAssetCloudIdEntityData(
assetId: serializer.fromJson<String>(json['assetId']),
cloudId: serializer.fromJson<String?>(json['cloudId']),
createdAt: serializer.fromJson<DateTime?>(json['createdAt']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId),
'cloudId': serializer.toJson<String?>(cloudId),
'createdAt': serializer.toJson<DateTime?>(createdAt),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
};
}
RemoteAssetCloudIdEntityData copyWith({
String? assetId,
Value<String?> cloudId = const Value.absent(),
Value<DateTime?> createdAt = const Value.absent(),
Value<DateTime?> adjustmentTime = const Value.absent(),
Value<double?> latitude = const Value.absent(),
Value<double?> longitude = const Value.absent(),
}) => RemoteAssetCloudIdEntityData(
assetId: assetId ?? this.assetId,
cloudId: cloudId.present ? cloudId.value : this.cloudId,
createdAt: createdAt.present ? createdAt.value : this.createdAt,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
);
RemoteAssetCloudIdEntityData copyWithCompanion(
RemoteAssetCloudIdEntityCompanion data,
) {
return RemoteAssetCloudIdEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
);
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityData(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is RemoteAssetCloudIdEntityData &&
other.assetId == this.assetId &&
other.cloudId == this.cloudId &&
other.createdAt == this.createdAt &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
}
class RemoteAssetCloudIdEntityCompanion
extends UpdateCompanion<RemoteAssetCloudIdEntityData> {
final Value<String> assetId;
final Value<String?> cloudId;
final Value<DateTime?> createdAt;
final Value<DateTime?> adjustmentTime;
final Value<double?> latitude;
final Value<double?> longitude;
const RemoteAssetCloudIdEntityCompanion({
this.assetId = const Value.absent(),
this.cloudId = const Value.absent(),
this.createdAt = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
});
RemoteAssetCloudIdEntityCompanion.insert({
required String assetId,
this.cloudId = const Value.absent(),
this.createdAt = const Value.absent(),
this.adjustmentTime = const Value.absent(),
this.latitude = const Value.absent(),
this.longitude = const Value.absent(),
}) : assetId = Value(assetId);
static Insertable<RemoteAssetCloudIdEntityData> custom({
Expression<String>? assetId,
Expression<String>? cloudId,
Expression<DateTime>? createdAt,
Expression<DateTime>? adjustmentTime,
Expression<double>? latitude,
Expression<double>? longitude,
}) {
return RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (cloudId != null) 'cloud_id': cloudId,
if (createdAt != null) 'created_at': createdAt,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
});
}
RemoteAssetCloudIdEntityCompanion copyWith({
Value<String>? assetId,
Value<String?>? cloudId,
Value<DateTime?>? createdAt,
Value<DateTime?>? adjustmentTime,
Value<double?>? latitude,
Value<double?>? longitude,
}) {
return RemoteAssetCloudIdEntityCompanion(
assetId: assetId ?? this.assetId,
cloudId: cloudId ?? this.cloudId,
createdAt: createdAt ?? this.createdAt,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (assetId.present) {
map['asset_id'] = Variable<String>(assetId.value);
}
if (cloudId.present) {
map['cloud_id'] = Variable<String>(cloudId.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = Variable<DateTime>(adjustmentTime.value);
}
if (latitude.present) {
map['latitude'] = Variable<double>(latitude.value);
}
if (longitude.present) {
map['longitude'] = Variable<double>(longitude.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityCompanion(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
}
class MemoryEntity extends Table
with TableInfo<MemoryEntity, MemoryEntityData> {
@override
@ -7702,6 +7323,13 @@ class TrashedLocalAssetEntity extends Table
requiredDuringInsert: false,
defaultValue: const CustomExpression('0'),
);
late final GeneratedColumn<int> source = GeneratedColumn<int>(
'source',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [
name,
@ -7716,6 +7344,7 @@ class TrashedLocalAssetEntity extends Table
checksum,
isFavorite,
orientation,
source,
];
@override
String get aliasedName => _alias ?? actualTableName;
@ -7779,6 +7408,10 @@ class TrashedLocalAssetEntity extends Table
DriftSqlType.int,
data['${effectivePrefix}orientation'],
)!,
source: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}source'],
)!,
);
}
@ -7807,6 +7440,7 @@ class TrashedLocalAssetEntityData extends DataClass
final String? checksum;
final bool isFavorite;
final int orientation;
final int source;
const TrashedLocalAssetEntityData({
required this.name,
required this.type,
@ -7820,6 +7454,7 @@ class TrashedLocalAssetEntityData extends DataClass
this.checksum,
required this.isFavorite,
required this.orientation,
required this.source,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@ -7844,6 +7479,7 @@ class TrashedLocalAssetEntityData extends DataClass
}
map['is_favorite'] = Variable<bool>(isFavorite);
map['orientation'] = Variable<int>(orientation);
map['source'] = Variable<int>(source);
return map;
}
@ -7865,6 +7501,7 @@ class TrashedLocalAssetEntityData extends DataClass
checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']),
source: serializer.fromJson<int>(json['source']),
);
}
@override
@ -7883,6 +7520,7 @@ class TrashedLocalAssetEntityData extends DataClass
'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation),
'source': serializer.toJson<int>(source),
};
}
@ -7899,6 +7537,7 @@ class TrashedLocalAssetEntityData extends DataClass
Value<String?> checksum = const Value.absent(),
bool? isFavorite,
int? orientation,
int? source,
}) => TrashedLocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@ -7914,6 +7553,7 @@ class TrashedLocalAssetEntityData extends DataClass
checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
);
TrashedLocalAssetEntityData copyWithCompanion(
TrashedLocalAssetEntityCompanion data,
@ -7937,6 +7577,7 @@ class TrashedLocalAssetEntityData extends DataClass
orientation: data.orientation.present
? data.orientation.value
: this.orientation,
source: data.source.present ? data.source.value : this.source,
);
}
@ -7954,7 +7595,8 @@ class TrashedLocalAssetEntityData extends DataClass
..write('albumId: $albumId, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation')
..write('orientation: $orientation, ')
..write('source: $source')
..write(')'))
.toString();
}
@ -7973,6 +7615,7 @@ class TrashedLocalAssetEntityData extends DataClass
checksum,
isFavorite,
orientation,
source,
);
@override
bool operator ==(Object other) =>
@ -7989,7 +7632,8 @@ class TrashedLocalAssetEntityData extends DataClass
other.albumId == this.albumId &&
other.checksum == this.checksum &&
other.isFavorite == this.isFavorite &&
other.orientation == this.orientation);
other.orientation == this.orientation &&
other.source == this.source);
}
class TrashedLocalAssetEntityCompanion
@ -8006,6 +7650,7 @@ class TrashedLocalAssetEntityCompanion
final Value<String?> checksum;
final Value<bool> isFavorite;
final Value<int> orientation;
final Value<int> source;
const TrashedLocalAssetEntityCompanion({
this.name = const Value.absent(),
this.type = const Value.absent(),
@ -8019,6 +7664,7 @@ class TrashedLocalAssetEntityCompanion
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
this.source = const Value.absent(),
});
TrashedLocalAssetEntityCompanion.insert({
required String name,
@ -8033,10 +7679,12 @@ class TrashedLocalAssetEntityCompanion
this.checksum = const Value.absent(),
this.isFavorite = const Value.absent(),
this.orientation = const Value.absent(),
required int source,
}) : name = Value(name),
type = Value(type),
id = Value(id),
albumId = Value(albumId);
albumId = Value(albumId),
source = Value(source);
static Insertable<TrashedLocalAssetEntityData> custom({
Expression<String>? name,
Expression<int>? type,
@ -8050,6 +7698,7 @@ class TrashedLocalAssetEntityCompanion
Expression<String>? checksum,
Expression<bool>? isFavorite,
Expression<int>? orientation,
Expression<int>? source,
}) {
return RawValuesInsertable({
if (name != null) 'name': name,
@ -8064,6 +7713,7 @@ class TrashedLocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation,
if (source != null) 'source': source,
});
}
@ -8080,6 +7730,7 @@ class TrashedLocalAssetEntityCompanion
Value<String?>? checksum,
Value<bool>? isFavorite,
Value<int>? orientation,
Value<int>? source,
}) {
return TrashedLocalAssetEntityCompanion(
name: name ?? this.name,
@ -8094,6 +7745,7 @@ class TrashedLocalAssetEntityCompanion
checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation,
source: source ?? this.source,
);
}
@ -8136,6 +7788,9 @@ class TrashedLocalAssetEntityCompanion
if (orientation.present) {
map['orientation'] = Variable<int>(orientation.value);
}
if (source.present) {
map['source'] = Variable<int>(source.value);
}
return map;
}
@ -8153,7 +7808,8 @@ class TrashedLocalAssetEntityCompanion
..write('albumId: $albumId, ')
..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation')
..write('orientation: $orientation, ')
..write('source: $source')
..write(')'))
.toString();
}
@ -8173,10 +7829,6 @@ class DatabaseAtV15 extends GeneratedDatabase {
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
late final Index idxLocalAssetCloudId = Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
late final Index idxRemoteAssetOwnerChecksum = Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
@ -8201,8 +7853,6 @@ class DatabaseAtV15 extends GeneratedDatabase {
RemoteAlbumAssetEntity(this);
late final RemoteAlbumUserEntity remoteAlbumUserEntity =
RemoteAlbumUserEntity(this);
late final RemoteAssetCloudIdEntity remoteAssetCloudIdEntity =
RemoteAssetCloudIdEntity(this);
late final MemoryEntity memoryEntity = MemoryEntity(this);
late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this);
late final PersonEntity personEntity = PersonEntity(this);
@ -8235,7 +7885,6 @@ class DatabaseAtV15 extends GeneratedDatabase {
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
@ -8246,7 +7895,6 @@ class DatabaseAtV15 extends GeneratedDatabase {
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,

File diff suppressed because it is too large Load Diff

View File

@ -94,25 +94,11 @@ abstract final class SyncStreamStub {
required String ack,
DateTime? trashedAt,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
ack: ack,
);
return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack);
}
static SyncEvent assetModified({
required String id,
required String checksum,
required String ack,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: null,
ack: ack,
);
static SyncEvent assetModified({required String id, required String checksum, required String ack}) {
return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack);
}
static SyncEvent _assetV1({
@ -140,6 +126,8 @@ abstract final class SyncStreamStub {
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
width: null,
height: null,
),
ack: ack,
);

View File

@ -45,5 +45,17 @@ void main() {
addDefault(value, keys, defaultValue);
expect(value['alpha']['beta'], 'gamma');
});
test('addDefault with null', () {
dynamic value = jsonDecode("""
{
"download": {
"archiveSize": 4294967296,
"includeEmbeddedVideos": false
}
}
""");
expect(value['download']['unknownKey'], isNull);
});
});
}

View File

@ -0,0 +1,118 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class MockDownloadRepository extends Mock implements DownloadRepository {}
void main() {
late ActionService sut;
late MockAssetApiRepository assetApiRepository;
late MockRemoteAssetRepository remoteAssetRepository;
late MockDriftLocalAssetRepository localAssetRepository;
late MockDriftAlbumApiRepository albumApiRepository;
late MockRemoteAlbumRepository remoteAlbumRepository;
late MockTrashedLocalAssetRepository trashedLocalAssetRepository;
late MockAssetMediaRepository assetMediaRepository;
late MockDownloadRepository downloadRepository;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
});
tearDownAll(() async {
debugDefaultTargetPlatformOverride = null;
await Store.clear();
await db.close();
});
setUp(() {
assetApiRepository = MockAssetApiRepository();
remoteAssetRepository = MockRemoteAssetRepository();
localAssetRepository = MockDriftLocalAssetRepository();
albumApiRepository = MockDriftAlbumApiRepository();
remoteAlbumRepository = MockRemoteAlbumRepository();
trashedLocalAssetRepository = MockTrashedLocalAssetRepository();
assetMediaRepository = MockAssetMediaRepository();
downloadRepository = MockDownloadRepository();
sut = ActionService(
assetApiRepository,
remoteAssetRepository,
localAssetRepository,
albumApiRepository,
remoteAlbumRepository,
trashedLocalAssetRepository,
assetMediaRepository,
downloadRepository,
);
});
tearDown(() async {
await Store.clear();
});
group('ActionService.deleteLocal', () {
test('routes deleted ids to trashed repository when Android trash handling is enabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['a', 'b'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
verifyNever(() => localAssetRepository.delete(any()));
});
test('deletes locally when Android trash handling is disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
const ids = ['c'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
final result = await sut.deleteLocal(ids);
expect(result, ids.length);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verify(() => localAssetRepository.delete(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
});
test('short-circuits when nothing was deleted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
const ids = ['x'];
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
final result = await sut.deleteLocal(ids);
expect(result, 0);
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
verifyNever(() => localAssetRepository.delete(any()));
});
});
}

View File

@ -3303,6 +3303,173 @@
"x-immich-state": "Stable"
}
},
"/assets/{id}/edits": {
"delete": {
"description": "Removes all edit actions (crop, rotate, mirror) associated with the specified asset.",
"operationId": "removeAssetEdits",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Remove edits from an existing asset",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2.5.0",
"state": "Added"
},
{
"version": "v2.5.0",
"state": "Beta"
}
],
"x-immich-permission": "asset.edit.delete",
"x-immich-state": "Beta"
},
"get": {
"description": "Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.",
"operationId": "getAssetEdits",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetEditsDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve edits for an existing asset",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2.5.0",
"state": "Added"
},
{
"version": "v2.5.0",
"state": "Beta"
}
],
"x-immich-permission": "asset.edit.get",
"x-immich-state": "Beta"
},
"put": {
"description": "Apply a series of edit actions (crop, rotate, mirror) to the specified asset.",
"operationId": "editAsset",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetEditActionListDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetEditsDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Apply edits to an existing asset",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v2.5.0",
"state": "Added"
},
{
"version": "v2.5.0",
"state": "Beta"
}
],
"x-immich-permission": "asset.edit.create",
"x-immich-state": "Beta"
}
},
"/assets/{id}/metadata": {
"get": {
"description": "Retrieve all metadata key-value pairs associated with the specified asset.",
@ -3632,6 +3799,15 @@
"description": "Downloads the original file of the specified asset.",
"operationId": "downloadAsset",
"parameters": [
{
"name": "edited",
"required": false,
"in": "query",
"schema": {
"default": false,
"type": "boolean"
}
},
{
"name": "id",
"required": true,
@ -3792,6 +3968,15 @@
"description": "Retrieve the thumbnail image for the specified asset.",
"operationId": "viewAsset",
"parameters": [
{
"name": "edited",
"required": false,
"in": "query",
"schema": {
"default": false,
"type": "boolean"
}
},
{
"name": "id",
"required": true,
@ -15286,6 +15471,128 @@
],
"type": "object"
},
"AssetEditAction": {
"enum": [
"crop",
"rotate",
"mirror"
],
"type": "string"
},
"AssetEditActionCrop": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/CropParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditActionListDto": {
"properties": {
"edits": {
"description": "list of edits",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/AssetEditActionCrop"
},
{
"$ref": "#/components/schemas/AssetEditActionRotate"
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
}
]
},
"minItems": 1,
"type": "array"
}
},
"required": [
"edits"
],
"type": "object"
},
"AssetEditActionMirror": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/MirrorParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditActionRotate": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/RotateParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditsDto": {
"properties": {
"assetId": {
"format": "uuid",
"type": "string"
},
"edits": {
"description": "list of edits",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/AssetEditActionCrop"
},
{
"$ref": "#/components/schemas/AssetEditActionRotate"
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
}
]
},
"minItems": 1,
"type": "array"
}
},
"required": [
"assetId",
"edits"
],
"type": "object"
},
"AssetFaceCreateDto": {
"properties": {
"assetId": {
@ -15959,6 +16266,10 @@
"hasMetadata": {
"type": "boolean"
},
"height": {
"nullable": true,
"type": "number"
},
"id": {
"type": "string"
},
@ -16079,6 +16390,10 @@
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"width": {
"nullable": true,
"type": "number"
}
},
"required": [
@ -16090,6 +16405,7 @@
"fileCreatedAt",
"fileModifiedAt",
"hasMetadata",
"height",
"id",
"isArchived",
"isFavorite",
@ -16102,7 +16418,8 @@
"thumbhash",
"type",
"updatedAt",
"visibility"
"visibility",
"width"
],
"type": "object"
},
@ -16466,6 +16783,37 @@
],
"type": "object"
},
"CropParameters": {
"properties": {
"height": {
"description": "Height of the crop",
"minimum": 1,
"type": "number"
},
"width": {
"description": "Width of the crop",
"minimum": 1,
"type": "number"
},
"x": {
"description": "Top-Left X coordinate of crop",
"minimum": 0,
"type": "number"
},
"y": {
"description": "Top-Left Y coordinate of crop",
"minimum": 0,
"type": "number"
}
},
"required": [
"height",
"width",
"x",
"y"
],
"type": "object"
},
"DatabaseBackupConfig": {
"properties": {
"cronExpression": {
@ -16865,6 +17213,7 @@
"AssetDetectFaces",
"AssetDetectDuplicatesQueueAll",
"AssetDetectDuplicates",
"AssetEditThumbnailGeneration",
"AssetEncodeVideoQueueAll",
"AssetEncodeVideo",
"AssetEmptyTrash",
@ -17620,6 +17969,30 @@
},
"type": "object"
},
"MirrorAxis": {
"description": "Axis to mirror along",
"enum": [
"horizontal",
"vertical"
],
"type": "string"
},
"MirrorParameters": {
"properties": {
"axis": {
"allOf": [
{
"$ref": "#/components/schemas/MirrorAxis"
}
],
"description": "Axis to mirror along"
}
},
"required": [
"axis"
],
"type": "object"
},
"NotificationCreateDto": {
"properties": {
"data": {
@ -18100,6 +18473,10 @@
"asset.upload",
"asset.replace",
"asset.copy",
"asset.derive",
"asset.edit.get",
"asset.edit.create",
"asset.edit.delete",
"album.create",
"album.read",
"album.update",
@ -18813,7 +19190,8 @@
"notifications",
"backupDatabase",
"ocr",
"workflow"
"workflow",
"editor"
],
"type": "string"
},
@ -18920,6 +19298,9 @@
"duplicateDetection": {
"$ref": "#/components/schemas/QueueResponseLegacyDto"
},
"editor": {
"$ref": "#/components/schemas/QueueResponseLegacyDto"
},
"faceDetection": {
"$ref": "#/components/schemas/QueueResponseLegacyDto"
},
@ -18967,6 +19348,7 @@
"backgroundTask",
"backupDatabase",
"duplicateDetection",
"editor",
"faceDetection",
"facialRecognition",
"library",
@ -19179,6 +19561,18 @@
],
"type": "object"
},
"RotateParameters": {
"properties": {
"angle": {
"description": "Rotation angle in degrees",
"type": "number"
}
},
"required": [
"angle"
],
"type": "object"
},
"SearchAlbumResponseDto": {
"properties": {
"count": {
@ -20892,6 +21286,10 @@
"nullable": true,
"type": "string"
},
"height": {
"nullable": true,
"type": "integer"
},
"id": {
"type": "string"
},
@ -20938,6 +21336,10 @@
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"width": {
"nullable": true,
"type": "integer"
}
},
"required": [
@ -20946,6 +21348,7 @@
"duration",
"fileCreatedAt",
"fileModifiedAt",
"height",
"id",
"isFavorite",
"libraryId",
@ -20956,7 +21359,8 @@
"stackId",
"thumbhash",
"type",
"visibility"
"visibility",
"width"
],
"type": "object"
},
@ -21809,6 +22213,9 @@
"backgroundTask": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"editor": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"faceDetection": {
"$ref": "#/components/schemas/JobSettingsDto"
},
@ -21848,6 +22255,7 @@
},
"required": [
"backgroundTask",
"editor",
"faceDetection",
"library",
"metadataExtraction",

View File

@ -349,6 +349,7 @@ export type AssetResponseDto = {
/** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */
fileModifiedAt: string;
hasMetadata: boolean;
height: number | null;
id: string;
isArchived: boolean;
isFavorite: boolean;
@ -373,6 +374,7 @@ export type AssetResponseDto = {
/** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */
updatedAt: string;
visibility: AssetVisibility;
width: number | null;
};
export type ContributorCountResponseDto = {
assetCount: number;
@ -574,6 +576,45 @@ export type UpdateAssetDto = {
rating?: number;
visibility?: AssetVisibility;
};
export type CropParameters = {
/** Height of the crop */
height: number;
/** Width of the crop */
width: number;
/** Top-Left X coordinate of crop */
x: number;
/** Top-Left Y coordinate of crop */
y: number;
};
export type AssetEditActionCrop = {
action: AssetEditAction;
parameters: CropParameters;
};
export type RotateParameters = {
/** Rotation angle in degrees */
angle: number;
};
export type AssetEditActionRotate = {
action: AssetEditAction;
parameters: RotateParameters;
};
export type MirrorParameters = {
/** Axis to mirror along */
axis: MirrorAxis;
};
export type AssetEditActionMirror = {
action: AssetEditAction;
parameters: MirrorParameters;
};
export type AssetEditsDto = {
assetId: string;
/** list of edits */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
};
export type AssetEditActionListDto = {
/** list of edits */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
};
export type AssetMetadataResponseDto = {
key: string;
updatedAt: string;
@ -749,6 +790,7 @@ export type QueuesResponseLegacyDto = {
backgroundTask: QueueResponseLegacyDto;
backupDatabase: QueueResponseLegacyDto;
duplicateDetection: QueueResponseLegacyDto;
editor: QueueResponseLegacyDto;
faceDetection: QueueResponseLegacyDto;
facialRecognition: QueueResponseLegacyDto;
library: QueueResponseLegacyDto;
@ -1484,6 +1526,7 @@ export type JobSettingsDto = {
};
export type SystemConfigJobDto = {
backgroundTask: JobSettingsDto;
editor: JobSettingsDto;
faceDetection: JobSettingsDto;
library: JobSettingsDto;
metadataExtraction: JobSettingsDto;
@ -2581,6 +2624,46 @@ export function updateAsset({ id, updateAssetDto }: {
body: updateAssetDto
})));
}
/**
* Remove edits from an existing asset
*/
export function removeAssetEdits({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/edits`, {
...opts,
method: "DELETE"
}));
}
/**
* Retrieve edits for an existing asset
*/
export function getAssetEdits({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetEditsDto;
}>(`/assets/${encodeURIComponent(id)}/edits`, {
...opts
}));
}
/**
* Apply edits to an existing asset
*/
export function editAsset({ id, assetEditActionListDto }: {
id: string;
assetEditActionListDto: AssetEditActionListDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetEditsDto;
}>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({
...opts,
method: "PUT",
body: assetEditActionListDto
})));
}
/**
* Get asset metadata
*/
@ -2652,7 +2735,8 @@ export function getAssetOcr({ id }: {
/**
* Download original asset
*/
export function downloadAsset({ id, key, slug }: {
export function downloadAsset({ edited, id, key, slug }: {
edited?: boolean;
id: string;
key?: string;
slug?: string;
@ -2661,6 +2745,7 @@ export function downloadAsset({ id, key, slug }: {
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
edited,
key,
slug
}))}`, {
@ -2691,7 +2776,8 @@ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
/**
* View asset thumbnail
*/
export function viewAsset({ id, key, size, slug }: {
export function viewAsset({ edited, id, key, size, slug }: {
edited?: boolean;
id: string;
key?: string;
size?: AssetMediaSize;
@ -2701,6 +2787,7 @@ export function viewAsset({ id, key, size, slug }: {
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({
edited,
key,
size,
slug
@ -5288,6 +5375,10 @@ export enum Permission {
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AssetDerive = "asset.derive",
AssetEditGet = "asset.edit.get",
AssetEditCreate = "asset.edit.create",
AssetEditDelete = "asset.edit.delete",
AlbumCreate = "album.create",
AlbumRead = "album.read",
AlbumUpdate = "album.update",
@ -5433,6 +5524,15 @@ export enum AssetJobName {
RegenerateThumbnail = "regenerate-thumbnail",
TranscodeVideo = "transcode-video"
}
export enum AssetEditAction {
Crop = "crop",
Rotate = "rotate",
Mirror = "mirror"
}
export enum MirrorAxis {
Horizontal = "horizontal",
Vertical = "vertical"
}
export enum AssetMediaSize {
Fullsize = "fullsize",
Preview = "preview",
@ -5463,7 +5563,8 @@ export enum QueueName {
Notifications = "notifications",
BackupDatabase = "backupDatabase",
Ocr = "ocr",
Workflow = "workflow"
Workflow = "workflow",
Editor = "editor"
}
export enum QueueCommand {
Start = "start",
@ -5508,6 +5609,7 @@ export enum JobName {
AssetDetectFaces = "AssetDetectFaces",
AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll",
AssetDetectDuplicates = "AssetDetectDuplicates",
AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration",
AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll",
AssetEncodeVideo = "AssetEncodeVideo",
AssetEmptyTrash = "AssetEmptyTrash",

18
pnpm-lock.yaml generated
View File

@ -565,6 +565,9 @@ importers:
thumbhash:
specifier: ^0.1.1
version: 0.1.1
transformation-matrix:
specifier: ^3.1.0
version: 3.1.0
ua-parser-js:
specifier: ^2.0.0
version: 2.0.7
@ -735,8 +738,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.54.0
version: 0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)
specifier: ^0.56.1
version: 0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@ -3084,8 +3087,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.54.0':
resolution: {integrity: sha512-6jvkvKhgsZ7LvspaJkbht/f8W5IRm+vjYkcZecShFAPaxaowbm7io9sO15MpJdIQfPdXg7vwLI527PV3vlBc6A==}
'@immich/ui@0.56.1':
resolution: {integrity: sha512-W4uEQn9pxVKRvIV7sl9p6dU2r7xlVsMFxBeClxtXzSsiJEoE10uZwBIm0L9q17c4TQ/+lk9e/w1e4jNSvFqFwQ==}
peerDependencies:
svelte: ^5.0.0
@ -11332,6 +11335,9 @@ packages:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
transformation-matrix@3.1.0:
resolution: {integrity: sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA==}
tree-dump@1.1.0:
resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==}
engines: {node: '>=10.0'}
@ -15098,7 +15104,7 @@ snapshots:
dependencies:
svelte: 5.46.1
'@immich/ui@0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)':
'@immich/ui@0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0
@ -24876,6 +24882,8 @@ snapshots:
punycode: 2.3.1
optional: true
transformation-matrix@3.1.0: {}
tree-dump@1.1.0(tslib@2.8.1):
dependencies:
tslib: 2.8.1

View File

@ -41,12 +41,11 @@
<a href="README_vi_VN.md">Tiếng Việt</a>
</p>
## ข้อควรระวัง
- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**มีการเปลี่ยนแปลงบ่อยมาก**
- ⚠️ อาจจะเกิดข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย
- ⚠️ **ห้ามใช้ระบบนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ**
- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ
> [!WARNING]
> ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ
>
> [!NOTE]
> คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/

View File

@ -110,6 +110,7 @@
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0"
@ -128,8 +129,8 @@
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.14.197",
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",

View File

@ -236,6 +236,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.Notification]: { concurrency: 5 },
[QueueName.Ocr]: { concurrency: 1 },
[QueueName.Workflow]: { concurrency: 5 },
[QueueName.Editor]: { concurrency: 2 },
},
logging: {
enabled: true,

View File

@ -33,6 +33,7 @@ import {
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
@ -104,10 +105,11 @@ export class AssetMediaController {
async downloadAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AssetDownloadOriginalDto,
@Res() res: Response,
@Next() next: NextFunction,
) {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
}
@Put(':id/original')

View File

@ -292,6 +292,64 @@ describe(AssetController.name, () => {
});
});
describe('PUT /assets/:id/edits', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should accept valid edits and pass to service correctly', async () => {
const edits = [
{
action: 'crop',
parameters: {
x: 0,
y: 0,
width: 100,
height: 100,
},
},
];
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}/edits`).send({
edits,
});
expect(service.editAsset).toHaveBeenCalledWith(undefined, assetId, { edits });
expect(status).toBe(200);
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/123/edits`)
.send({
edits: [
{
action: 'crop',
parameters: {
x: 0,
y: 0,
width: 100,
height: 100,
},
},
],
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require at least one edit', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/edits`)
.send({ edits: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['edits must contain at least 1 elements']));
});
});
describe('DELETE /assets/:id/metadata/:key', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);

View File

@ -20,6 +20,7 @@ import {
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@ -226,4 +227,42 @@ export class AssetController {
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
return this.service.deleteMetadataByKey(auth, id, key);
}
@Get(':id/edits')
@Authenticated({ permission: Permission.AssetEditGet })
@Endpoint({
summary: 'Retrieve edits for an existing asset',
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
})
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> {
return this.service.getAssetEdits(auth, id);
}
@Put(':id/edits')
@Authenticated({ permission: Permission.AssetEditCreate })
@Endpoint({
summary: 'Apply edits to an existing asset',
description: 'Apply a series of edit actions (crop, rotate, mirror) to the specified asset.',
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
})
editAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetEditActionListDto,
): Promise<AssetEditsDto> {
return this.service.editAsset(auth, id, dto);
}
@Delete(':id/edits')
@Authenticated({ permission: Permission.AssetEditDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Remove edits from an existing asset',
description: 'Removes all edit actions (crop, rotate, mirror) associated with the specified asset.',
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
})
removeAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.removeAssetEdits(auth, id);
}
}

View File

@ -24,7 +24,13 @@ export interface MoveRequest {
};
}
export type GeneratedImageType = AssetPathType.Preview | AssetPathType.Thumbnail | AssetPathType.FullSize;
export type GeneratedImageType =
| AssetPathType.Preview
| AssetPathType.Thumbnail
| AssetPathType.FullSize
| AssetPathType.EditedPreview
| AssetPathType.EditedThumbnail
| AssetPathType.EditedFullSize;
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
export type ThumbnailPathEntity = { id: string; ownerId: string };

View File

@ -272,6 +272,7 @@ export type AssetFace = {
person?: Person | null;
updatedAt: Date;
updateId: string;
isVisible: boolean;
};
export type Plugin = Selectable<PluginTable>;
@ -340,6 +341,8 @@ export const columns = {
'asset.originalPath',
'asset.ownerId',
'asset.type',
'asset.width',
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
@ -390,6 +393,8 @@ export const columns = {
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],

View File

@ -19,6 +19,9 @@ export enum AssetMediaSize {
export class AssetMediaOptionsDto {
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', optional: true })
size?: AssetMediaSize;
@ValidateBoolean({ optional: true, default: false })
edited?: boolean;
}
export enum UploadFieldName {

View File

@ -3,6 +3,7 @@ import { Selectable } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
import {
AssetFaceWithoutPersonResponseDto,
@ -13,6 +14,8 @@ import {
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { ImageDimensions } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
import { ValidateEnum } from 'src/validation';
@ -34,6 +37,8 @@ export class SanitizedAssetResponseDto {
duration!: string;
livePhotoVideoId?: string | null;
hasMetadata!: boolean;
width!: number | null;
height!: number | null;
}
export class AssetResponseDto extends SanitizedAssetResponseDto {
@ -107,6 +112,7 @@ export type MapAsset = {
deviceId: string;
duplicateId: string | null;
duration: string | null;
edits?: AssetEditActionItem[];
encodedVideoPath: string | null;
exifInfo?: Selectable<Exif> | null;
faces?: AssetFace[];
@ -129,6 +135,8 @@ export type MapAsset = {
tags?: Tag[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
width: number | null;
height: number | null;
};
export class AssetStackResponseDto {
@ -147,7 +155,11 @@ export type AssetMapOptions = {
};
// TODO: this is inefficient
const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
const peopleWithFaces = (
faces?: AssetFace[],
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = [];
if (faces) {
for (const face of faces) {
@ -156,7 +168,7 @@ const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
if (existingPersonEntry) {
existingPersonEntry.faces.push(face);
} else {
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] });
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] });
}
}
}
@ -190,10 +202,14 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
width: entity.width,
height: entity.height,
};
return sanitizedAssetResponse as AssetResponseDto;
}
const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined;
return {
id: entity.id,
createdAt: entity.createdAt,
@ -219,7 +235,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces),
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
checksum: hexOrBufferToBase64(entity.checksum)!,
stack: withStack ? mapStack(entity) : undefined,
@ -227,5 +243,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
hasMetadata: true,
duplicateId: entity.duplicateId,
resized: true,
width: entity.width,
height: entity.height,
};
}

View File

@ -228,6 +228,11 @@ export class AssetCopyDto {
favorite?: boolean;
}
export class AssetDownloadOriginalDto {
@ValidateBoolean({ optional: true, default: false })
edited?: boolean;
}
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.Image],

View File

@ -0,0 +1,125 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
export enum AssetEditAction {
Crop = 'crop',
Rotate = 'rotate',
Mirror = 'mirror',
}
export enum MirrorAxis {
Horizontal = 'horizontal',
Vertical = 'vertical',
}
export class CropParameters {
@IsInt()
@Min(0)
@ApiProperty({ description: 'Top-Left X coordinate of crop' })
x!: number;
@IsInt()
@Min(0)
@ApiProperty({ description: 'Top-Left Y coordinate of crop' })
y!: number;
@IsInt()
@Min(1)
@ApiProperty({ description: 'Width of the crop' })
width!: number;
@IsInt()
@Min(1)
@ApiProperty({ description: 'Height of the crop' })
height!: number;
}
export class RotateParameters {
@IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number;
}
export class MirrorParameters {
@IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
axis!: MirrorAxis;
}
class AssetEditActionBase {
@IsEnum(AssetEditAction)
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction' })
action!: AssetEditAction;
}
export class AssetEditActionCrop extends AssetEditActionBase {
@ValidateNested()
@Type(() => CropParameters)
@ApiProperty({ type: CropParameters })
parameters!: CropParameters;
}
export class AssetEditActionRotate extends AssetEditActionBase {
@ValidateNested()
@Type(() => RotateParameters)
@ApiProperty({ type: RotateParameters })
parameters!: RotateParameters;
}
export class AssetEditActionMirror extends AssetEditActionBase {
@ValidateNested()
@Type(() => MirrorParameters)
@ApiProperty({ type: MirrorParameters })
parameters!: MirrorParameters;
}
export type AssetEditActionItem =
| {
action: AssetEditAction.Crop;
parameters: CropParameters;
}
| {
action: AssetEditAction.Rotate;
parameters: RotateParameters;
}
| {
action: AssetEditAction.Mirror;
parameters: MirrorParameters;
};
export type AssetEditActionParameter = {
[AssetEditAction.Crop]: CropParameters;
[AssetEditAction.Rotate]: RotateParameters;
[AssetEditAction.Mirror]: MirrorParameters;
};
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
[AssetEditAction.Crop]: AssetEditActionCrop,
[AssetEditAction.Rotate]: AssetEditActionRotate,
[AssetEditAction.Mirror]: AssetEditActionMirror,
} as const;
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
actionToClass[item.action];
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
export class AssetEditActionListDto {
/** list of edits */
@ArrayMinSize(1)
@IsUniqueEditActions()
@ValidateNested({ each: true })
@Transform(({ value: edits }) =>
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits,
)
@ApiProperty({ anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })) })
edits!: AssetEditActionItem[];
}
export class AssetEditsDto extends AssetEditActionListDto {
@ValidateUUID()
@ApiProperty()
assetId!: string;
}

View File

@ -6,9 +6,12 @@ import { DateTime } from 'luxon';
import { AssetFace, Person } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceType } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { ImageDimensions } from 'src/types';
import { asDateString } from 'src/utils/date';
import { transformFaceBoundingBox } from 'src/utils/transform';
import {
IsDateStringFormat,
MaxDateString,
@ -233,29 +236,37 @@ export function mapPerson(person: Person): PersonResponseDto {
};
}
export function mapFacesWithoutPerson(face: Selectable<AssetFaceTable>): AssetFaceWithoutPersonResponseDto {
export function mapFacesWithoutPerson(
face: Selectable<AssetFaceTable>,
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceWithoutPersonResponseDto {
return {
id: face.id,
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
boundingBoxX1: face.boundingBoxX1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY1: face.boundingBoxY1,
boundingBoxY2: face.boundingBoxY2,
...transformFaceBoundingBox(
{
boundingBoxX1: face.boundingBoxX1,
boundingBoxY1: face.boundingBoxY1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY2: face.boundingBoxY2,
imageWidth: face.imageWidth,
imageHeight: face.imageHeight,
},
edits ?? [],
assetDimensions ?? { width: face.imageWidth, height: face.imageHeight },
),
sourceType: face.sourceType,
};
}
export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto {
export function mapFaces(
face: AssetFace,
auth: AuthDto,
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceResponseDto {
return {
id: face.id,
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
boundingBoxX1: face.boundingBoxX1,
boundingBoxX2: face.boundingBoxX2,
boundingBoxY1: face.boundingBoxY1,
boundingBoxY2: face.boundingBoxY2,
sourceType: face.sourceType,
...mapFacesWithoutPerson(face, edits, assetDimensions),
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
};
}

View File

@ -66,6 +66,9 @@ export class QueuesResponseLegacyDto implements Record<QueueName, QueueResponseL
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Workflow]!: QueueResponseLegacyDto;
@ApiProperty({ type: QueueResponseLegacyDto })
[QueueName.Editor]!: QueueResponseLegacyDto;
}
export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => {

View File

@ -117,6 +117,10 @@ export class SyncAssetV1 {
livePhotoVideoId!: string | null;
stackId!: string | null;
libraryId!: string | null;
@ApiProperty({ type: 'integer' })
width!: number | null;
@ApiProperty({ type: 'integer' })
height!: number | null;
}
@ExtraModel()

View File

@ -230,6 +230,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.Workflow]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.Editor]!: JobSettingsDto;
}
class SystemConfigLibraryScanDto {

View File

@ -45,6 +45,9 @@ export enum AssetFileType {
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
FullSizeEdited = 'fullsize_edited',
PreviewEdited = 'preview_edited',
ThumbnailEdited = 'thumbnail_edited',
}
export enum AlbumUserRole {
@ -106,6 +109,11 @@ export enum Permission {
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',
AssetEditGet = 'asset.edit.get',
AssetEditCreate = 'asset.edit.create',
AssetEditDelete = 'asset.edit.delete',
AlbumCreate = 'album.create',
AlbumRead = 'album.read',
@ -358,6 +366,9 @@ export enum AssetPathType {
Original = 'original',
FullSize = 'fullsize',
Preview = 'preview',
EditedFullSize = 'edited_fullsize',
EditedPreview = 'edited_preview',
EditedThumbnail = 'edited_thumbnail',
Thumbnail = 'thumbnail',
EncodedVideo = 'encoded_video',
Sidecar = 'sidecar',
@ -555,6 +566,7 @@ export enum QueueName {
BackupDatabase = 'backupDatabase',
Ocr = 'ocr',
Workflow = 'workflow',
Editor = 'editor',
}
export enum QueueJobStatus {
@ -573,6 +585,7 @@ export enum JobName {
AssetDetectFaces = 'AssetDetectFaces',
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
AssetDetectDuplicates = 'AssetDetectDuplicates',
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
AssetEncodeVideo = 'AssetEncodeVideo',
AssetEmptyTrash = 'AssetEmptyTrash',

View File

@ -0,0 +1,17 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AssetEditRepository.replaceAll
begin
delete from "asset_edit"
where
"assetId" = $1
rollback
-- AssetEditRepository.getAll
select
"action",
"parameters"
from
"asset_edit"
where
"assetId" = $1

View File

@ -105,7 +105,21 @@ select
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files"
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits"
from
"asset"
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
@ -167,6 +181,20 @@ select
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits",
to_json("asset_exif") as "exifInfo"
from
"asset"
@ -191,6 +219,8 @@ select
"asset"."originalPath",
"asset"."ownerId",
"asset"."type",
"asset"."width",
"asset"."height",
(
select
coalesce(json_agg(agg), '[]')
@ -203,6 +233,7 @@ select
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $1
) as agg
) as "faces",
(
@ -218,13 +249,13 @@ select
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."type" = $2
) as agg
) as "files"
from
"asset"
where
"asset"."id" = $2
"asset"."id" = $3
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
select
@ -402,6 +433,7 @@ select
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
) as agg
) as "faces",
(

View File

@ -49,6 +49,23 @@ returning
"dateTimeOriginal",
"timeZone"
-- AssetRepository.unlockProperties
update "asset_exif"
set
"lockedProperties" = nullif(
array(
select distinct
property
from
unnest("asset_exif"."lockedProperties") property
where
not property = any ($1)
),
'{}'
)
where
"assetId" = $2
-- AssetRepository.getMetadata
select
"key",
@ -182,6 +199,7 @@ select
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
) as agg
) as "faces",
(
@ -383,14 +401,10 @@ with
"asset_exif"."projectionType",
coalesce(
case
when asset_exif."exifImageHeight" = 0
or asset_exif."exifImageWidth" = 0 then 1
when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric,
3
)
when asset."height" = 0
or asset."width" = 0 then 1
else round(
asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric,
asset."width"::numeric / asset."height"::numeric,
3
)
end,

View File

@ -15,6 +15,7 @@ from
"asset_ocr"
where
"asset_ocr"."assetId" = $1
and "asset_ocr"."isVisible" = $2
-- OcrRepository.upsert
with
@ -66,3 +67,12 @@ with
)
select
1 as "dummy"
-- OcrRepository.updateOcrVisibilities
begin
update "ocr_search"
set
"text" = $1
where
"assetId" = $2
commit

View File

@ -35,6 +35,7 @@ from
where
"person"."ownerId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $2
group by
"person"."id"
@ -63,6 +64,7 @@ from
left join "asset_face" on "asset_face"."personId" = "person"."id"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
group by
"person"."id"
having
@ -89,6 +91,7 @@ from
where
"asset_face"."assetId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
order by
"asset_face"."boundingBoxX1" asc
@ -229,6 +232,7 @@ from
and "asset"."deletedAt" is null
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
-- PersonRepository.getNumberOfPeople
select
@ -250,6 +254,7 @@ where
where
"asset_face"."personId" = "person"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
select
from
@ -260,7 +265,7 @@ where
and "asset"."deletedAt" is null
)
)
and "person"."ownerId" = $2
and "person"."ownerId" = $3
-- PersonRepository.refreshFaces
with
@ -321,6 +326,7 @@ from
where
"asset_face"."personId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
-- PersonRepository.getLatestFaceDate
select

Some files were not shown because too many files have changed in this diff Show More