diff --git a/docs/docs/guides/img/administration-link.webp b/docs/docs/guides/img/administration-link.webp index cbed69782a..22bc4e4c87 100644 Binary files a/docs/docs/guides/img/administration-link.webp and b/docs/docs/guides/img/administration-link.webp differ diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 1826002af6..a56118a68b 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -11,11 +11,24 @@ describe('/people', () => { let hiddenPerson: PersonResponseDto; let multipleAssetsPerson: PersonResponseDto; + let nameAlicePerson: PersonResponseDto; + let nameBobPerson: PersonResponseDto; + let nameCharliePerson: PersonResponseDto; + let nameNullPerson: PersonResponseDto; + beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - [visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([ + [ + visiblePerson, + hiddenPerson, + multipleAssetsPerson, + nameCharliePerson, + nameBobPerson, + nameAlicePerson, + nameNullPerson, + ] = await Promise.all([ utils.createPerson(admin.accessToken, { name: 'visible_person', }), @@ -26,10 +39,24 @@ describe('/people', () => { utils.createPerson(admin.accessToken, { name: 'multiple_assets_person', }), + // --- Setup for the specific sorting test --- + utils.createPerson(admin.accessToken, { + name: 'Charlie', + }), + utils.createPerson(admin.accessToken, { + name: 'Bob', + }), + utils.createPerson(admin.accessToken, { + name: 'Alice', + }), + utils.createPerson(admin.accessToken, { + name: '', + }), ]); const asset1 = await utils.createAsset(admin.accessToken); const asset2 = await utils.createAsset(admin.accessToken); + const asset3 = await utils.createAsset(admin.accessToken); await Promise.all([ utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), @@ -37,6 +64,15 @@ describe('/people', () => { utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), + // Named persons + utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset + utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }), + utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets + utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset + // Null-named person + utils.createFace({ assetId: asset1.id, personId: nameNullPerson.id }), + utils.createFace({ assetId: asset2.id, personId: nameNullPerson.id }), // 2 assets ]); }); @@ -51,26 +87,53 @@ describe('/people', () => { expect(status).toBe(200); expect(body).toEqual({ hasNextPage: false, - total: 3, + total: 7, hidden: 1, people: [ expect.objectContaining({ name: 'multiple_assets_person' }), + expect.objectContaining({ name: 'Bob' }), + expect.objectContaining({ name: 'Alice' }), + expect.objectContaining({ name: 'Charlie' }), expect.objectContaining({ name: 'visible_person' }), expect.objectContaining({ name: 'hidden_person' }), ], }); }); + it('should sort visible people by asset count (desc), then by name (asc, nulls last)', async () => { + const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body.hasNextPage).toBe(false); + expect(body.total).toBe(7); // All persons + expect(body.hidden).toBe(1); // 'hidden_person' + + const people = body.people as PersonResponseDto[]; + + expect(people.map((p) => p.id)).toEqual([ + multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3 + nameBobPerson.id, // name: 'Bob', count: 2 + nameAlicePerson.id, // name: 'Alice', count: 1 + nameCharliePerson.id, // name: 'Charlie', count: 1 + visiblePerson.id, // name: 'visible_person', count: 1 + ]); + + expect(people.some((p) => p.id === hiddenPerson.id)).toBe(false); + }); + it('should return only visible people', async () => { const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ hasNextPage: false, - total: 3, + total: 7, hidden: 1, people: [ expect.objectContaining({ name: 'multiple_assets_person' }), + expect.objectContaining({ name: 'Bob' }), + expect.objectContaining({ name: 'Alice' }), + expect.objectContaining({ name: 'Charlie' }), expect.objectContaining({ name: 'visible_person' }), ], }); @@ -80,12 +143,12 @@ describe('/people', () => { const { status, body } = await request(app) .get('/people') .set('Authorization', `Bearer ${admin.accessToken}`) - .query({ withHidden: true, page: 2, size: 1 }); + .query({ withHidden: true, page: 5, size: 1 }); expect(status).toBe(200); expect(body).toEqual({ hasNextPage: true, - total: 3, + total: 7, hidden: 1, people: [expect.objectContaining({ name: 'visible_person' })], }); @@ -128,7 +191,7 @@ describe('/people', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ assets: 2 })); + expect(body).toEqual(expect.objectContaining({ assets: 3 })); }); }); diff --git a/i18n/en.json b/i18n/en.json index d8b4bd55ca..fafa137f4d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -244,7 +244,7 @@ "storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the {job}.", "storage_template_migration_job": "Storage Template Migration Job", "storage_template_more_details": "For more details about this feature, refer to the Storage Template and its implications", - "storage_template_onboarding_description": "When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the feature has been turned off by default. For more information, please see the documentation.", + "storage_template_onboarding_description_v2": "When enabled, this feature will auto-organize files based on a user-defined template. For more information, please see the documentation.", "storage_template_path_length": "Approximate path length limit: {length, number}/{limit, number}", "storage_template_settings": "Storage Template", "storage_template_settings_description": "Manage the folder structure and file name of the upload asset", diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index b338b7b758..7ca6b7a2b8 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -56,7 +56,7 @@ custom_lint: allowed: # required / wanted - 'lib/infrastructure/repositories/album_media.repository.dart' - - 'lib/infrastructure/repositories/storage.repository.dart' + - 'lib/infrastructure/repositories/{storage,asset_media}.repository.dart' - 'lib/repositories/{album,asset,file}_media.repository.dart' # acceptable exceptions for the time being - lib/entities/asset.entity.dart # to provide local AssetEntity for now diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 7f8ad531be..9ec0d763f7 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -83,7 +83,11 @@ open class NativeSyncApiImplBase(context: Context) { continue } - val mediaType = c.getInt(mediaTypeColumn) + val mediaType = when (c.getInt(mediaTypeColumn)) { + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1 + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2 + else -> 0 + } val name = c.getString(nameColumn) // Date taken is milliseconds since epoch, Date added is seconds since epoch val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) diff --git a/mobile/build.yaml b/mobile/build.yaml index d5de77a377..76cc0a9988 100644 --- a/mobile/build.yaml +++ b/mobile/build.yaml @@ -17,6 +17,7 @@ targets: main: lib/infrastructure/repositories/db.repository.dart generate_for: &drift_generate_for - lib/infrastructure/entities/*.dart + - lib/infrastructure/entities/*.drift - lib/infrastructure/repositories/db.repository.dart drift_dev:modular: enabled: true diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 0d147b9b22..1b2c86026c 100644 --- a/mobile/drift_schemas/main/drift_schema_v1.json +++ b/mobile/drift_schemas/main/drift_schema_v1.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4,3],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":6,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":7,"references":[6],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":9,"references":[6],"type":"index","data":{"on":6,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":4,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":6,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":7,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":8,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":9,"references":[2,8],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":10,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":11,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetOrder.values)","dart_type_name":"AssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":12,"references":[1,11],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":13,"references":[11,0],"type":"table","data":{"name":"album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}}]} \ No newline at end of file diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 861e8196c5..85f3b1fcfb 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -36,6 +36,7 @@ class NativeSyncApiImpl: NativeSyncApi { private let defaults: UserDefaults private let changeTokenKey = "immich:changeToken" private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] + private let recoveredAlbumSubType = 1000000219 private let hashBufferSize = 2 * 1024 * 1024 @@ -91,9 +92,17 @@ class NativeSyncApiImpl: NativeSyncApi { albumTypes.forEach { type in let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) - collections.enumerateObjects { (album, _, _) in + for i in 0.. %@ OR modificationDate > %@", date, date) + options.includeHiddenAssets = false let assets = PHAsset.fetchAssets(in: album, options: options) return Int64(assets.count) } @@ -230,6 +245,7 @@ class NativeSyncApiImpl: NativeSyncApi { } let options = PHFetchOptions() + options.includeHiddenAssets = false if(updatedTimeCond != nil) { let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 03b6e6b6c8..cb73c2819a 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -20,3 +20,8 @@ const String kSecuredPinCode = "secured_pin_code"; const kManualUploadGroup = 'manual_upload_group'; const kBackupGroup = 'backup_group'; const kBackupLivePhotoGroup = 'backup_live_photo_group'; + +// Timeline constants +const int kTimelineNoneSegmentSize = 120; +const int kTimelineAssetLoadBatchSize = 256; +const int kTimelineAssetLoadOppositeSize = 64; diff --git a/mobile/lib/domain/interfaces/asset_media.interface.dart b/mobile/lib/domain/interfaces/asset_media.interface.dart new file mode 100644 index 0000000000..93f99827ee --- /dev/null +++ b/mobile/lib/domain/interfaces/asset_media.interface.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +abstract interface class IAssetMediaRepository { + Future getThumbnail( + String id, { + int quality = 80, + Size size = const Size.square(256), + }); +} diff --git a/mobile/lib/domain/interfaces/timeline.interface.dart b/mobile/lib/domain/interfaces/timeline.interface.dart new file mode 100644 index 0000000000..e60dd83b50 --- /dev/null +++ b/mobile/lib/domain/interfaces/timeline.interface.dart @@ -0,0 +1,27 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; + +abstract interface class ITimelineRepository implements IDatabaseRepository { + Stream> watchMainBucket( + List timelineUsers, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }); + + Future> getMainBucketAssets( + List timelineUsers, { + required int offset, + required int count, + }); + + Stream> watchLocalBucket( + String albumId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }); + + Future> getLocalBucketAssets( + String albumId, { + required int offset, + required int count, + }); +} diff --git a/mobile/lib/domain/models/asset/asset.model.dart b/mobile/lib/domain/models/asset/asset.model.dart index c170f7f848..b0a0b0dfb9 100644 --- a/mobile/lib/domain/models/asset/asset.model.dart +++ b/mobile/lib/domain/models/asset/asset.model.dart @@ -11,6 +11,7 @@ enum AssetVisibility { class Asset extends BaseAsset { final String id; final String? localId; + final String? thumbHash; final AssetVisibility visibility; const Asset({ @@ -25,9 +26,14 @@ class Asset extends BaseAsset { super.height, super.durationInSeconds, super.isFavorite = false, + this.thumbHash, this.visibility = AssetVisibility.timeline, }); + @override + AssetState get storage => + localId == null ? AssetState.remote : AssetState.merged; + @override String toString() { return '''Asset { @@ -41,6 +47,7 @@ class Asset extends BaseAsset { durationInSeconds: ${durationInSeconds ?? ""}, localId: ${localId ?? ""}, isFavorite: $isFavorite, + thumbHash: ${thumbHash ?? ""}, visibility: $visibility, }'''; } @@ -52,10 +59,15 @@ class Asset extends BaseAsset { return super == other && id == other.id && localId == other.localId && + thumbHash == other.thumbHash && visibility == other.visibility; } @override int get hashCode => - super.hashCode ^ id.hashCode ^ localId.hashCode ^ visibility.hashCode; + super.hashCode ^ + id.hashCode ^ + localId.hashCode ^ + thumbHash.hashCode ^ + visibility.hashCode; } diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index fb95437659..509998a109 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -9,6 +9,12 @@ enum AssetType { audio, } +enum AssetState { + local, + remote, + merged, +} + sealed class BaseAsset { final String name; final String? checksum; @@ -32,6 +38,10 @@ sealed class BaseAsset { this.isFavorite = false, }); + bool get isImage => type == AssetType.image; + bool get isVideo => type == AssetType.video; + AssetState get storage; + @override String toString() { return '''BaseAsset { diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 25e617d8ed..95eb1bce9f 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -18,6 +18,10 @@ class LocalAsset extends BaseAsset { super.isFavorite = false, }); + @override + AssetState get storage => + remoteId == null ? AssetState.local : AssetState.merged; + @override String toString() { return '''LocalAsset { diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart new file mode 100644 index 0000000000..d975cbb4fe --- /dev/null +++ b/mobile/lib/domain/models/setting.model.dart @@ -0,0 +1,12 @@ +import 'package:immich_mobile/domain/models/store.model.dart'; + +enum Setting { + tilesPerRow(StoreKey.tilesPerRow, 4), + groupAssetsBy(StoreKey.groupAssetsBy, 0), + showStorageIndicator(StoreKey.storageIndicator, true); + + const Setting(this.storeKey, this.defaultValue); + + final StoreKey storeKey; + final T defaultValue; +} diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart new file mode 100644 index 0000000000..4a49708b74 --- /dev/null +++ b/mobile/lib/domain/models/timeline.model.dart @@ -0,0 +1,40 @@ +enum GroupAssetsBy { + day, + month, + none; +} + +enum HeaderType { + none, + month, + day, + monthAndDay; +} + +class Bucket { + final int assetCount; + + const Bucket({required this.assetCount}); + + @override + bool operator ==(covariant Bucket other) { + return assetCount == other.assetCount; + } + + @override + int get hashCode => assetCount.hashCode; +} + +class TimeBucket extends Bucket { + final DateTime date; + + const TimeBucket({required this.date, required super.assetCount}); + + @override + bool operator ==(covariant TimeBucket other) { + return super == other && date == other.date; + } + + @override + int get hashCode => super.hashCode ^ date.hashCode; +} diff --git a/mobile/lib/domain/services/setting.service.dart b/mobile/lib/domain/services/setting.service.dart new file mode 100644 index 0000000000..2d1937be5a --- /dev/null +++ b/mobile/lib/domain/services/setting.service.dart @@ -0,0 +1,19 @@ +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; + +class SettingsService { + final StoreService _storeService; + + const SettingsService({required StoreService storeService}) + : _storeService = storeService; + + T get(Setting setting) => + _storeService.get(setting.storeKey, setting.defaultValue); + + Future set(Setting setting, T value) => + _storeService.put(setting.storeKey, value); + + Stream watch(Setting setting) => _storeService + .watch(setting.storeKey) + .map((v) => v ?? setting.defaultValue); +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart new file mode 100644 index 0000000000..d1211f46e2 --- /dev/null +++ b/mobile/lib/domain/services/timeline.service.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/utils/async_mutex.dart'; + +typedef TimelineAssetSource = Future> Function( + int index, + int count, +); + +typedef TimelineBucketSource = Stream> Function(); + +class TimelineFactory { + final ITimelineRepository _timelineRepository; + final SettingsService _settingsService; + + const TimelineFactory({ + required ITimelineRepository timelineRepository, + required SettingsService settingsService, + }) : _timelineRepository = timelineRepository, + _settingsService = settingsService; + + GroupAssetsBy get groupBy => + GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)]; + + TimelineService main(List timelineUsers) => TimelineService( + assetSource: (offset, count) => _timelineRepository + .getMainBucketAssets(timelineUsers, offset: offset, count: count), + bucketSource: () => _timelineRepository.watchMainBucket( + timelineUsers, + groupBy: groupBy, + ), + ); + + TimelineService localAlbum({required String albumId}) => TimelineService( + assetSource: (offset, count) => _timelineRepository + .getLocalBucketAssets(albumId, offset: offset, count: count), + bucketSource: () => + _timelineRepository.watchLocalBucket(albumId, groupBy: groupBy), + ); +} + +class TimelineService { + final TimelineAssetSource _assetSource; + final TimelineBucketSource _bucketSource; + + TimelineService({ + required TimelineAssetSource assetSource, + required TimelineBucketSource bucketSource, + }) : _assetSource = assetSource, + _bucketSource = bucketSource { + _bucketSubscription = + _bucketSource().listen((_) => unawaited(_reloadBucket())); + } + + final AsyncMutex _mutex = AsyncMutex(); + int _bufferOffset = 0; + List _buffer = []; + StreamSubscription? _bucketSubscription; + + Stream> Function() get watchBuckets => _bucketSource; + + Future _reloadBucket() => _mutex.run(() async { + _buffer = await _assetSource(_bufferOffset, _buffer.length); + }); + + Future> loadAssets(int index, int count) => + _mutex.run(() => _loadAssets(index, count)); + + Future> _loadAssets(int index, int count) async { + if (hasRange(index, count)) { + return getAssets(index, count); + } + + // if the requested offset is greater than the cached offset, the user scrolls forward "down" + final bool forward = _bufferOffset < index; + + // make sure to load a meaningful amount of data (and not only the requested slice) + // otherwise, each call to [loadAssets] would result in DB call trashing performance + // fills small requests to [kTimelineAssetLoadBatchSize], adds some legroom into the opposite scroll direction for large requests + final len = math.max( + kTimelineAssetLoadBatchSize, + count + kTimelineAssetLoadOppositeSize, + ); + // when scrolling forward, start shortly before the requested offset + // when scrolling backward, end shortly after the requested offset to guard against the user scrolling + // in the other direction a tiny bit resulting in another required load from the DB + final start = math.max( + 0, + forward + ? index - kTimelineAssetLoadOppositeSize + : (len > kTimelineAssetLoadBatchSize ? index : index + count - len), + ); + + final assets = await _assetSource(start, len); + _buffer = assets; + _bufferOffset = start; + + return getAssets(index, count); + } + + bool hasRange(int index, int count) => + index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length; + + List getAssets(int index, int count) { + if (!hasRange(index, count)) { + throw RangeError('TimelineService::getAssets Index out of range'); + } + int start = index - _bufferOffset; + return _buffer.slice(start, start + count); + } + + Future dispose() async { + await _bucketSubscription?.cancel(); + _bucketSubscription = null; + _buffer.clear(); + _bufferOffset = 0; + } +} diff --git a/mobile/lib/extensions/asyncvalue_extensions.dart b/mobile/lib/extensions/asyncvalue_extensions.dart index f30b5481bd..554c3a8a8a 100644 --- a/mobile/lib/extensions/asyncvalue_extensions.dart +++ b/mobile/lib/extensions/asyncvalue_extensions.dart @@ -23,9 +23,7 @@ extension LogOnError on AsyncValue { if (!skip) { return onLoading?.call() ?? - const Center( - child: ImmichLoadingIndicator(), - ); + const Center(child: ImmichLoadingIndicator()); } } diff --git a/mobile/lib/extensions/translate_extensions.dart b/mobile/lib/extensions/translate_extensions.dart new file mode 100644 index 0000000000..122830843d --- /dev/null +++ b/mobile/lib/extensions/translate_extensions.dart @@ -0,0 +1,50 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:intl/message_format.dart'; +import 'package:flutter/material.dart'; + +extension StringTranslateExtension on String { + String t({BuildContext? context, Map? args}) { + return _translateHelper(context, this, args); + } +} + +extension TextTranslateExtension on Text { + Text t({BuildContext? context, Map? args}) { + return Text( + _translateHelper(context, data ?? '', args), + key: key, + style: style, + strutStyle: strutStyle, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + semanticsLabel: semanticsLabel, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + ); + } +} + +String _translateHelper( + BuildContext? context, + String key, [ + Map? args, +]) { + if (key.isEmpty) { + return ''; + } + try { + final translatedMessage = key.tr(context: context); + return args != null + ? MessageFormat(translatedMessage, locale: Intl.defaultLocale ?? 'en') + .format(args) + : translatedMessage; + } catch (e) { + debugPrint('Translation failed for key "$key". Error: $e'); + return key; + } +} diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index ff5ee74818..39c3822b04 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -1,4 +1,6 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @@ -15,3 +17,16 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { @override Set get primaryKey => {id}; } + +extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { + LocalAsset toDto() => LocalAsset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + ); +} diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift new file mode 100644 index 0000000000..51f731f0ff --- /dev/null +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -0,0 +1,81 @@ +import 'remote_asset.entity.dart'; +import 'local_asset.entity.dart'; + +mergedAsset: SELECT * FROM +( + SELECT + rae.id as remote_id, + lae.id as local_id, + rae.name, + rae."type", + rae.created_at, + rae.updated_at, + rae.width, + rae.height, + rae.duration_in_seconds, + rae.is_favorite, + rae.thumb_hash, + rae.checksum, + rae.owner_id + FROM + remote_asset_entity rae + LEFT JOIN + local_asset_entity lae ON rae.checksum = lae.checksum + WHERE + rae.visibility = 0 AND rae.owner_id in ? + UNION ALL + SELECT + NULL as remote_id, + lae.id as local_id, + lae.name, + lae."type", + lae.created_at, + lae.updated_at, + lae.width, + lae.height, + lae.duration_in_seconds, + lae.is_favorite, + NULL as thumb_hash, + lae.checksum, + NULL as owner_id + FROM + local_asset_entity lae + LEFT JOIN + remote_asset_entity rae ON rae.checksum = lae.checksum + WHERE + rae.id IS NULL +) +ORDER BY created_at DESC +LIMIT $limit; + +mergedBucket(:group_by AS INTEGER): +SELECT + COUNT(*) as asset_count, + CASE + WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day + WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month + END AS bucket_date +FROM +( + SELECT + rae.name, + rae.created_at + FROM + remote_asset_entity rae + LEFT JOIN + local_asset_entity lae ON rae.checksum = lae.checksum + WHERE + rae.visibility = 0 AND rae.owner_id in ? + UNION ALL + SELECT + lae.name, + lae.created_at + FROM + local_asset_entity lae + LEFT JOIN + remote_asset_entity rae ON rae.checksum = lae.checksum + WHERE + rae.id IS NULL +) +GROUP BY bucket_date +ORDER BY bucket_date DESC; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart new file mode 100644 index 0000000000..be9d8b521e --- /dev/null +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -0,0 +1,114 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:drift/internal/modular.dart' as i1; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i3; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' + as i4; + +class MergedAssetDrift extends i1.ModularAccessor { + MergedAssetDrift(i0.GeneratedDatabase db) : super(db); + i0.Selectable mergedAsset(List var1, + {required i0.Limit limit}) { + var $arrayStartIndex = 1; + final expandedvar1 = $expandVar($arrayStartIndex, var1.length); + $arrayStartIndex += var1.length; + final generatedlimit = $write(limit, startIndex: $arrayStartIndex); + $arrayStartIndex += generatedlimit.amountOfVariables; + return customSelect( + 'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}', + variables: [ + for (var $ in var1) i0.Variable($), + ...generatedlimit.introducedVariables + ], + readsFrom: { + remoteAssetEntity, + localAssetEntity, + ...generatedlimit.watchedTables, + }).map((i0.QueryRow row) => MergedAssetResult( + remoteId: row.readNullable('remote_id'), + localId: row.readNullable('local_id'), + name: row.read('name'), + type: i3.$RemoteAssetEntityTable.$convertertype + .fromSql(row.read('type')), + createdAt: row.read('created_at'), + updatedAt: row.read('updated_at'), + width: row.readNullable('width'), + height: row.readNullable('height'), + durationInSeconds: row.readNullable('duration_in_seconds'), + isFavorite: row.read('is_favorite'), + thumbHash: row.readNullable('thumb_hash'), + checksum: row.readNullable('checksum'), + ownerId: row.readNullable('owner_id'), + )); + } + + i0.Selectable mergedBucket(List var2, + {required int groupBy}) { + var $arrayStartIndex = 2; + final expandedvar2 = $expandVar($arrayStartIndex, var2.length); + $arrayStartIndex += var2.length; + return customSelect( + 'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at) END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC', + variables: [ + i0.Variable(groupBy), + for (var $ in var2) i0.Variable($) + ], + readsFrom: { + remoteAssetEntity, + localAssetEntity, + }).map((i0.QueryRow row) => MergedBucketResult( + assetCount: row.read('asset_count'), + bucketDate: row.read('bucket_date'), + )); + } + + i3.$RemoteAssetEntityTable get remoteAssetEntity => + i1.ReadDatabaseContainer(attachedDatabase) + .resultSet('remote_asset_entity'); + i4.$LocalAssetEntityTable get localAssetEntity => + i1.ReadDatabaseContainer(attachedDatabase) + .resultSet('local_asset_entity'); +} + +class MergedAssetResult { + final String? remoteId; + final String? localId; + final String name; + final i2.AssetType type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final bool isFavorite; + final String? thumbHash; + final String? checksum; + final String? ownerId; + MergedAssetResult({ + this.remoteId, + this.localId, + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.isFavorite, + this.thumbHash, + this.checksum, + this.ownerId, + }); +} + +class MergedBucketResult { + final int assetCount; + final String bucketDate; + MergedBucketResult({ + required this.assetCount, + required this.bucketDate, + }); +} diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 96f4077a2a..3c7589949f 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; columns: {#checksum, #ownerId}, unique: true, ) +@TableIndex(name: 'idx_remote_asset_checksum', columns: {#checksum}) class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const RemoteAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index c244b5a09a..4a13b74f5d 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart @@ -1178,3 +1178,6 @@ class RemoteAssetEntityCompanion .toString(); } } + +i0.Index get idxRemoteAssetChecksum => i0.Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); diff --git a/mobile/lib/infrastructure/repositories/asset_media.repository.dart b/mobile/lib/infrastructure/repositories/asset_media.repository.dart new file mode 100644 index 0000000000..d46c340028 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/asset_media.repository.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class AssetMediaRepository implements IAssetMediaRepository { + const AssetMediaRepository(); + + @override + Future getThumbnail( + String id, { + int quality = 80, + Size size = const Size.square(256), + }) => + AssetEntity( + id: id, + // The below fields are not used in thumbnailDataWithSize but are required + // to create an AssetEntity instance. It is faster to create a dummy AssetEntity + // instance than to fetch the asset from the device first. + typeInt: AssetType.image.index, + width: size.width.toInt(), + height: size.height.toInt(), + ).thumbnailDataWithSize( + ThumbnailSize(size.width.toInt(), size.height.toInt()), + quality: quality, + ); +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 4ad60276a2..15b19f5c80 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -41,6 +41,9 @@ class IsarDatabaseRepository implements IDatabaseRepository { RemoteAssetEntity, RemoteExifEntity, ], + include: { + 'package:immich_mobile/infrastructure/entities/merged_asset.drift', + }, ) class Drift extends $Drift implements IDatabaseRepository { Drift([QueryExecutor? executor]) diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index d1bda93653..d088e5420a 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -3,59 +3,72 @@ import 'package:drift/drift.dart' as i0; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart' as i1; -import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' - as i2; -import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' - as i3; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' - as i4; -import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' - as i5; -import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' - as i6; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i2; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' + as i3; +import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' + as i5; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' + as i6; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' as i7; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart' as i8; +import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' + as i9; +import 'package:drift/internal/modular.dart' as i10; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); $DriftManager get managers => $DriftManager(this); late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this); - late final i2.$UserMetadataEntityTable userMetadataEntity = - i2.$UserMetadataEntityTable(this); - late final i3.$PartnerEntityTable partnerEntity = - i3.$PartnerEntityTable(this); - late final i4.$LocalAlbumEntityTable localAlbumEntity = - i4.$LocalAlbumEntityTable(this); - late final i5.$LocalAssetEntityTable localAssetEntity = - i5.$LocalAssetEntityTable(this); - late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity = - i6.$LocalAlbumAssetEntityTable(this); - late final i7.$RemoteAssetEntityTable remoteAssetEntity = - i7.$RemoteAssetEntityTable(this); + late final i2.$RemoteAssetEntityTable remoteAssetEntity = + i2.$RemoteAssetEntityTable(this); + late final i3.$LocalAssetEntityTable localAssetEntity = + i3.$LocalAssetEntityTable(this); + late final i4.$UserMetadataEntityTable userMetadataEntity = + i4.$UserMetadataEntityTable(this); + late final i5.$PartnerEntityTable partnerEntity = + i5.$PartnerEntityTable(this); + late final i6.$LocalAlbumEntityTable localAlbumEntity = + i6.$LocalAlbumEntityTable(this); + late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = + i7.$LocalAlbumAssetEntityTable(this); late final i8.$RemoteExifEntityTable remoteExifEntity = i8.$RemoteExifEntityTable(this); + i9.MergedAssetDrift get mergedAssetDrift => i10.ReadDatabaseContainer(this) + .accessor(i9.MergedAssetDrift.new); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override List get allSchemaEntities => [ userEntity, + remoteAssetEntity, + localAssetEntity, + i3.idxLocalAssetChecksum, + i2.uQRemoteAssetOwnerChecksum, + i2.idxRemoteAssetChecksum, userMetadataEntity, partnerEntity, localAlbumEntity, - localAssetEntity, localAlbumAssetEntity, - remoteAssetEntity, - remoteExifEntity, - i5.idxLocalAssetChecksum, - i7.uQRemoteAssetOwnerChecksum + remoteExifEntity ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => const i0.StreamQueryUpdateRules( [ + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('user_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete), + ], + ), i0.WritePropagation( on: i0.TableUpdateQuery.onTableName('user_entity', limitUpdateKind: i0.UpdateKind.delete), @@ -94,13 +107,6 @@ abstract class $Drift extends i0.GeneratedDatabase { kind: i0.UpdateKind.delete), ], ), - i0.WritePropagation( - on: i0.TableUpdateQuery.onTableName('user_entity', - limitUpdateKind: i0.UpdateKind.delete), - result: [ - i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete), - ], - ), i0.WritePropagation( on: i0.TableUpdateQuery.onTableName('remote_asset_entity', limitUpdateKind: i0.UpdateKind.delete), @@ -120,18 +126,18 @@ class $DriftManager { $DriftManager(this._db); i1.$$UserEntityTableTableManager get userEntity => i1.$$UserEntityTableTableManager(_db, _db.userEntity); - i2.$$UserMetadataEntityTableTableManager get userMetadataEntity => - i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); - i3.$$PartnerEntityTableTableManager get partnerEntity => - i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); - i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity => - i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); - i5.$$LocalAssetEntityTableTableManager get localAssetEntity => - i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); - i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6 + i2.$$RemoteAssetEntityTableTableManager get remoteAssetEntity => + i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity); + i3.$$LocalAssetEntityTableTableManager get localAssetEntity => + i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); + i4.$$UserMetadataEntityTableTableManager get userMetadataEntity => + i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); + i5.$$PartnerEntityTableTableManager get partnerEntity => + i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); + i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity => + i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); + i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7 .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity); - i7.$$RemoteAssetEntityTableTableManager get remoteAssetEntity => - i7.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity); i8.$$RemoteExifEntityTableTableManager get remoteExifEntity => i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart new file mode 100644 index 0000000000..909332ec6e --- /dev/null +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:stream_transform/stream_transform.dart'; + +class DriftTimelineRepository extends DriftDatabaseRepository + implements ITimelineRepository { + final Drift _db; + + const DriftTimelineRepository(super._db) : _db = _db; + + List _generateBuckets(int count) { + final numBuckets = (count / kTimelineNoneSegmentSize).floor(); + final buckets = List.generate( + numBuckets, + (_) => const Bucket(assetCount: kTimelineNoneSegmentSize), + ); + if (count % kTimelineNoneSegmentSize != 0) { + buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize)); + } + return buckets; + } + + @override + Stream> watchMainBucket( + List userIds, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + throw UnsupportedError( + "GroupAssetsBy.none is not supported for watchMainBucket", + ); + } + + return _db.mergedAssetDrift + .mergedBucket(userIds, groupBy: groupBy.index) + .map((row) { + final date = row.bucketDate.dateFmt(groupBy); + return TimeBucket(date: date, assetCount: row.assetCount); + }) + .watch() + .throttle(const Duration(seconds: 3), trailing: true); + } + + @override + Future> getMainBucketAssets( + List userIds, { + required int offset, + required int count, + }) { + return _db.mergedAssetDrift + .mergedAsset(userIds, limit: Limit(count, offset)) + .map( + (row) => row.remoteId != null + ? Asset( + id: row.remoteId!, + localId: row.localId, + name: row.name, + checksum: row.checksum, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + thumbHash: row.thumbHash, + width: row.width, + height: row.height, + isFavorite: row.isFavorite, + durationInSeconds: row.durationInSeconds, + ) + : LocalAsset( + id: row.localId!, + remoteId: row.remoteId, + name: row.name, + checksum: row.checksum, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + width: row.width, + height: row.height, + isFavorite: row.isFavorite, + durationInSeconds: row.durationInSeconds, + ), + ) + .get(); + } + + @override + Stream> watchLocalBucket( + String albumId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + return _db.localAlbumAssetEntity + .count(where: (row) => row.albumId.equals(albumId)) + .map(_generateBuckets) + .watchSingle(); + } + + final assetCountExp = _db.localAssetEntity.id.count(); + final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy); + + final query = _db.localAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ]) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + @override + Future> getLocalBucketAssets( + String albumId, { + required int offset, + required int count, + }) { + final query = _db.localAssetEntity.select().join( + [ + innerJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ], + ) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) + ..limit(count, offset: offset); + return query + .map((row) => row.readTable(_db.localAssetEntity).toDto()) + .get(); + } +} + +extension on Expression { + Expression dateFmt(GroupAssetsBy groupBy) { + // DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting + // to create the correct time bucket + final localTimeExp = modify(const DateTimeModifier.localTime()); + return switch (groupBy) { + GroupAssetsBy.day => localTimeExp.date, + GroupAssetsBy.month => localTimeExp.strftime("%Y-%m"), + GroupAssetsBy.none => throw ArgumentError( + "GroupAssetsBy.none is not supported for date formatting", + ), + }; + } +} + +extension on String { + DateTime dateFmt(GroupAssetsBy groupBy) { + final format = switch (groupBy) { + GroupAssetsBy.day => "y-M-d", + GroupAssetsBy.month => "y-M", + GroupAssetsBy.none => throw ArgumentError( + "GroupAssetsBy.none is not supported for date formatting", + ), + }; + try { + return DateFormat(format).parse(this); + } catch (e) { + throw FormatException("Invalid date format: $this", e); + } + } +} diff --git a/mobile/lib/pages/albums/albums.page.dart b/mobile/lib/pages/albums/albums.page.dart index 9d8ebb7673..2a13ccccd7 100644 --- a/mobile/lib/pages/albums/albums.page.dart +++ b/mobile/lib/pages/albums/albums.page.dart @@ -8,13 +8,13 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; @@ -230,11 +230,17 @@ class AlbumsPage extends HookConsumerWidget { ), subtitle: sorted[index].ownerId != null ? Text( - '${t('items_count', { - 'count': sorted[index].assetCount, - })} • ${sorted[index].ownerId != userId ? t('shared_by_user', { - 'user': sorted[index].ownerName!, - }) : 'owned'.tr()}', + '${'items_count'.t( + context: context, + args: { + 'count': sorted[index].assetCount, + }, + )} • ${sorted[index].ownerId != userId ? 'shared_by_user'.t( + context: context, + args: { + 'user': sorted[index].ownerName!, + }, + ) : 'owned'.t(context: context)}', overflow: TextOverflow.ellipsis, style: context.textTheme.bodyMedium?.copyWith( diff --git a/mobile/lib/pages/library/local_albums.page.dart b/mobile/lib/pages/library/local_albums.page.dart index 5ce6d453ae..9eceaca205 100644 --- a/mobile/lib/pages/library/local_albums.page.dart +++ b/mobile/lib/pages/library/local_albums.page.dart @@ -2,9 +2,9 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -46,7 +46,10 @@ class LocalAlbumsPage extends HookConsumerWidget { ), ), subtitle: Text( - t('items_count', {'count': albums[index].assetCount}), + 'items_count'.t( + context: context, + args: {'count': albums[index].assetCount}, + ), style: context.textTheme.bodyMedium?.copyWith( color: context.colorScheme.onSurfaceSecondary, ), diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index cc397364eb..c40e742111 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -48,6 +48,10 @@ final _features = [ ), _Feature( name: 'Clear Local Data', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), icon: Icons.delete_forever_rounded, onTap: (_, ref) async { final db = ref.read(driftProvider); @@ -58,6 +62,10 @@ final _features = [ ), _Feature( name: 'Clear Remote Data', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), icon: Icons.delete_sweep_rounded, onTap: (_, ref) async { final db = ref.read(driftProvider); @@ -67,17 +75,29 @@ final _features = [ ), _Feature( name: 'Local Media Summary', + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), icon: Icons.table_chart_rounded, onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()), ), _Feature( name: 'Remote Media Summary', + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), icon: Icons.summarize_rounded, onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()), ), _Feature( name: 'Reset Sqlite', icon: Icons.table_view_rounded, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), onTap: (_, ref) async { final drift = ref.read(driftProvider); // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member @@ -88,6 +108,11 @@ final _features = [ } }, ), + _Feature( + name: 'Main Timeline', + icon: Icons.timeline_rounded, + onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()), + ), ]; @RoutePage() @@ -110,7 +135,10 @@ class FeatInDevPage extends StatelessWidget { final feat = _features[index]; return Consumer( builder: (ctx, ref, _) => ListTile( - title: Text(feat.name), + title: Text( + feat.name, + style: feat.style, + ), trailing: Icon(feat.icon), visualDensity: VisualDensity.compact, onTap: () => unawaited(feat.onTap(ctx, ref)), @@ -133,10 +161,13 @@ class _Feature { required this.name, required this.icon, required this.onTap, + // ignore: unused_element_parameter + this.style, }); final String name; final IconData icon; + final TextStyle? style; final Future Function(BuildContext, WidgetRef _) onTap; } diff --git a/mobile/lib/presentation/pages/dev/local_timeline.page.dart b/mobile/lib/presentation/pages/dev/local_timeline.page.dart new file mode 100644 index 0000000000..8c06a6b62a --- /dev/null +++ b/mobile/lib/presentation/pages/dev/local_timeline.page.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +@RoutePage() +class LocalTimelinePage extends StatelessWidget { + final String albumId; + + const LocalTimelinePage({super.key, required this.albumId}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = + ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId); + ref.onDispose(() => unawaited(timelineService.dispose())); + return timelineService; + }, + ), + ], + child: const Timeline(), + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart new file mode 100644 index 0000000000..8c04f129eb --- /dev/null +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; + +@RoutePage() +class MainTimelinePage extends StatelessWidget { + const MainTimelinePage({super.key}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .main(ref.watch(timelineUsersIdsProvider)); + ref.onDispose(() => unawaited(timelineService.dispose())); + return timelineService; + }, + ), + ], + child: const Timeline(), + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index 7028f8e4e4..cc1fd0ae0c 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; class _Stat { const _Stat({required this.name, required this.load}); @@ -16,9 +17,16 @@ class _Stat { class _Summary extends StatelessWidget { final String name; + final Widget? leading; final Future countFuture; + final void Function()? onTap; - const _Summary({required this.name, required this.countFuture}); + const _Summary({ + required this.name, + required this.countFuture, + this.leading, + this.onTap, + }); @override Widget build(BuildContext context) { @@ -34,7 +42,12 @@ class _Summary extends StatelessWidget { } else { subtitle = Text('${snapshot.data ?? 0}'); } - return ListTile(title: Text(name), trailing: subtitle); + return ListTile( + leading: leading, + title: Text(name), + trailing: subtitle, + onTap: onTap, + ); }, ); } @@ -105,8 +118,12 @@ class LocalMediaSummaryPage extends StatelessWidget { .filter((f) => f.albumId.id.equals(album.id)) .count(); return _Summary( + leading: const Icon(Icons.photo_album_rounded), name: album.name, countFuture: countFuture, + onTap: () => context.router.push( + LocalTimelineRoute(albumId: album.id), + ), ); }, itemCount: albums.length, diff --git a/mobile/lib/presentation/widgets/images/local_thumb_provider.dart b/mobile/lib/presentation/widgets/images/local_thumb_provider.dart new file mode 100644 index 0000000000..607057cf44 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/local_thumb_provider.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; + +class LocalThumbProvider extends ImageProvider { + final IAssetMediaRepository _assetMediaRepository = + const AssetMediaRepository(); + final CacheManager? cacheManager; + + final LocalAsset asset; + final double height; + final double width; + + LocalThumbProvider({ + required this.asset, + this.height = kTimelineFixedTileExtent, + this.width = kTimelineFixedTileExtent, + this.cacheManager, + }); + + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + LocalThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ThumbnailImageCacheManager(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, cache, decode), + scale: 1.0, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset', key.asset), + ], + ); + } + + Future _codec( + LocalThumbProvider key, + CacheManager cache, + ImageDecoderCallback decode, + ) async { + final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height'; + + final fileFromCache = await cache.getFileFromCache(cacheKey); + if (fileFromCache != null) { + try { + final buffer = + await ImmutableBuffer.fromFilePath(fileFromCache.file.path); + return await decode(buffer); + } catch (_) {} + } + + final thumbnailBytes = await _assetMediaRepository.getThumbnail( + key.asset.id, + size: Size(key.width, key.height), + ); + if (thumbnailBytes == null) { + PaintingBinding.instance.imageCache.evict(key); + throw StateError( + "Loading thumb for local photo ${key.asset.name} failed", + ); + } + + final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); + unawaited(cache.putFile(cacheKey, thumbnailBytes)); + return decode(buffer); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is LocalThumbProvider) { + return asset.id == other.asset.id && + asset.updatedAt == other.asset.updatedAt; + } + return false; + } + + @override + int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart b/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart new file mode 100644 index 0000000000..c9561ee156 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/image/cache/image_loader.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class RemoteThumbProvider extends ImageProvider { + final String assetId; + final double height; + final double width; + final CacheManager? cacheManager; + + RemoteThumbProvider({ + required this.assetId, + this.height = kTimelineFixedTileExtent, + this.width = kTimelineFixedTileExtent, + this.cacheManager, + }); + + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + RemoteThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ThumbnailImageCacheManager(); + final chunkController = StreamController(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, cache, decode, chunkController), + scale: 1.0, + chunkEvents: chunkController.stream, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + ], + ); + } + + Future _codec( + RemoteThumbProvider key, + CacheManager cache, + ImageDecoderCallback decode, + StreamController chunkController, + ) async { + final preview = getThumbnailUrlForRemoteId( + key.assetId, + ); + + return ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + chunkEvents: chunkController, + ).whenComplete(chunkController.close); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is RemoteThumbProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart new file mode 100644 index 0000000000..308c92e968 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart @@ -0,0 +1,50 @@ +import 'dart:convert' hide Codec; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:thumbhash/thumbhash.dart'; + +class ThumbHashProvider extends ImageProvider { + final String thumbHash; + + ThumbHashProvider({ + required this.thumbHash, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + ThumbHashProvider key, + ImageDecoderCallback decode, + ) { + return MultiFrameImageStreamCompleter( + codec: _loadCodec(key, decode), + scale: 1.0, + ); + } + + Future _loadCodec( + ThumbHashProvider key, + ImageDecoderCallback decode, + ) async { + final image = thumbHashToRGBA(base64Decode(key.thumbHash)); + return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image))); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is ThumbHashProvider) { + return thumbHash == other.thumbHash; + } + return false; + } + + @override + int get hashCode => thumbHash.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart new file mode 100644 index 0000000000..e9648ab06e --- /dev/null +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart'; +import 'package:logging/logging.dart'; +import 'package:octo_image/octo_image.dart'; + +class Thumbnail extends StatelessWidget { + const Thumbnail({ + required this.asset, + this.size = const Size.square(256), + this.fit = BoxFit.cover, + super.key, + }); + + final BaseAsset asset; + final Size size; + final BoxFit fit; + + static ImageProvider imageProvider({ + required BaseAsset asset, + Size size = const Size.square(256), + }) { + if (asset is LocalAsset) { + return LocalThumbProvider( + asset: asset, + height: size.height, + width: size.width, + ); + } + + if (asset is Asset) { + return RemoteThumbProvider( + assetId: asset.id, + height: size.height, + width: size.width, + ); + } + + throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); + } + + @override + Widget build(BuildContext context) { + final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null; + final provider = imageProvider(asset: asset, size: size); + + return OctoImage.fromSet( + image: provider, + octoSet: OctoSet( + placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit), + errorBuilder: _blurHashErrorBuilder( + thumbHash, + provider: provider, + fit: fit, + asset: asset, + ), + ), + fadeOutDuration: const Duration(milliseconds: 100), + fadeInDuration: Duration.zero, + width: size.width, + height: size.height, + fit: fit, + placeholderFadeInDuration: Duration.zero, + ); + } +} + +OctoPlaceholderBuilder _blurHashPlaceholderBuilder( + String? thumbHash, { + BoxFit? fit, +}) { + return (context) => thumbHash == null + ? const ThumbnailPlaceholder() + : FadeInPlaceholderImage( + placeholder: const ThumbnailPlaceholder(), + image: ThumbHashProvider(thumbHash: thumbHash), + fit: fit ?? BoxFit.cover, + ); +} + +OctoErrorBuilder _blurHashErrorBuilder( + String? blurhash, { + BaseAsset? asset, + ImageProvider? provider, + BoxFit? fit, +}) => + (context, e, s) { + Logger("ImThumbnail") + .warning("Error loading thumbnail for ${asset?.name}", e, s); + provider?.evict(); + return Stack( + alignment: Alignment.center, + children: [ + _blurHashPlaceholderBuilder(blurhash, fit: fit)(context), + const Opacity( + opacity: 0.75, + child: Icon(Icons.error_outline_rounded), + ), + ], + ); + }; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart new file mode 100644 index 0000000000..eb64b2dcd3 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; + +class ThumbnailTile extends StatelessWidget { + const ThumbnailTile( + this.asset, { + this.size = const Size.square(256), + this.fit = BoxFit.cover, + this.showStorageIndicator = true, + super.key, + }); + + final BaseAsset asset; + final Size size; + final BoxFit fit; + final bool showStorageIndicator; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)), + if (asset.isVideo) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, top: 6.0), + child: _VideoIndicator(asset.durationInSeconds ?? 0), + ), + ), + if (showStorageIndicator) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, bottom: 6.0), + child: _TileOverlayIcon( + switch (asset.storage) { + AssetState.local => Icons.cloud_off_outlined, + AssetState.remote => Icons.cloud_outlined, + AssetState.merged => Icons.cloud_done_outlined, + }, + ), + ), + ), + if (asset.isFavorite) + const Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: EdgeInsets.only(left: 10.0, bottom: 6.0), + child: _TileOverlayIcon(Icons.favorite_rounded), + ), + ), + ], + ); + } +} + +class _VideoIndicator extends StatelessWidget { + final int durationInSeconds; + const _VideoIndicator(this.durationInSeconds); + + String _formatDuration(int durationInSec) { + final int hours = durationInSec ~/ 3600; + final int minutes = (durationInSec % 3600) ~/ 60; + final int seconds = durationInSec % 60; + + final String minutesPadded = minutes.toString().padLeft(2, '0'); + final String secondsPadded = seconds.toString().padLeft(2, '0'); + + if (hours > 0) { + return "$hours:$minutesPadded:$secondsPadded"; // H:MM:SS + } else { + return "$minutesPadded:$secondsPadded"; // MM:SS + } + } + + @override + Widget build(BuildContext context) { + return Row( + spacing: 3, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + // CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _formatDuration(durationInSeconds), + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Colors.black.withValues(alpha: 0.6), + ), + ], + ), + ), + const _TileOverlayIcon(Icons.play_circle_outline_rounded), + ], + ); + } +} + +class _TileOverlayIcon extends StatelessWidget { + final IconData icon; + + const _TileOverlayIcon(this.icon); + + @override + Widget build(BuildContext context) { + return Icon( + icon, + color: Colors.white, + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Colors.black.withValues(alpha: 0.6), + offset: const Offset(0.0, 0.0), + ), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart new file mode 100644 index 0000000000..fb9034f179 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -0,0 +1,7 @@ +const double kTimelineHeaderExtent = 80.0; +const double kTimelineFixedTileExtent = 256; +const double kTimelineSpacing = 2.0; +const int kTimelineColumnCount = 3; + +const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); +const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); diff --git a/mobile/lib/presentation/widgets/timeline/fixed/row.dart b/mobile/lib/presentation/widgets/timeline/fixed/row.dart new file mode 100644 index 0000000000..1062c00740 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/fixed/row.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class FixedTimelineRow extends MultiChildRenderObjectWidget { + final double dimension; + final double spacing; + final TextDirection textDirection; + + const FixedTimelineRow({ + super.key, + required this.dimension, + required this.spacing, + required this.textDirection, + required super.children, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderFixedRow( + dimension: dimension, + spacing: spacing, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { + renderObject.dimension = dimension; + renderObject.spacing = spacing; + renderObject.textDirection = textDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } +} + +class _RowParentData extends ContainerBoxParentData {} + +class RenderFixedRow extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + RenderFixedRow({ + List? children, + required double dimension, + required double spacing, + required TextDirection textDirection, + }) : _dimension = dimension, + _spacing = spacing, + _textDirection = textDirection { + addAll(children); + } + + double get dimension => _dimension; + double _dimension; + + set dimension(double value) { + if (_dimension == value) return; + _dimension = value; + markNeedsLayout(); + } + + double get spacing => _spacing; + double _spacing; + + set spacing(double value) { + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _RowParentData) { + child.parentData = _RowParentData(); + } + } + + double get intrinsicWidth => + dimension * childCount + spacing * (childCount - 1); + + @override + double computeMinIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMaxIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMinIntrinsicHeight(double width) => dimension; + + @override + double computeMaxIntrinsicHeight(double width) => dimension; + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } + + @override + void performLayout() { + RenderBox? child = firstChild; + if (child == null) { + size = constraints.smallest; + return; + } + // Use the entire width of the parent for the row. + size = Size(constraints.maxWidth, dimension); + // Each tile is forced to be dimension x dimension. + final childConstraints = BoxConstraints.tight(Size(dimension, dimension)); + final flipMainAxis = textDirection == TextDirection.rtl; + Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); + final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); + // Layout each child horizontally. + while (child != null) { + child.layout(childConstraints, parentUsesSize: false); + final childParentData = child.parentData! as _RowParentData; + childParentData.offset = offset; + offset += Offset(dx, 0); + child = childParentData.nextSibling; + } + } +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart new file mode 100644 index 0000000000..bea754b3ff --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class FixedSegment extends Segment { + final double tileHeight; + final int columnCount; + final double mainAxisExtend; + + const FixedSegment({ + required super.firstIndex, + required super.lastIndex, + required super.startOffset, + required super.endOffset, + required super.firstAssetIndex, + required super.bucket, + required this.tileHeight, + required this.columnCount, + required super.headerExtent, + required super.spacing, + required super.header, + }) : assert(tileHeight != 0), + mainAxisExtend = tileHeight + spacing; + + @override + double indexToLayoutOffset(int index) { + index -= gridIndex; + if (index < 0) { + return startOffset; + } + return gridOffset + (mainAxisExtend * index); + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= gridOffset; + if (!scrollOffset.isFinite || scrollOffset < 0) { + return firstIndex; + } + final rowsAbove = (scrollOffset / mainAxisExtend).floor(); + return gridIndex + rowsAbove; + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= gridOffset; + if (!scrollOffset.isFinite || scrollOffset < 0) { + return firstIndex; + } + final firstRowBelow = (scrollOffset / mainAxisExtend).ceil(); + return gridIndex + firstRowBelow - 1; + } + + @override + Widget builder(BuildContext context, int index) { + if (index == firstIndex) { + return TimelineHeader( + bucket: bucket, + header: header, + height: headerExtent, + ); + } + + final rowIndexInSegment = index - (firstIndex + 1); + final assetIndex = rowIndexInSegment * columnCount; + final assetCount = bucket.assetCount; + final numberOfAssets = math.min(columnCount, assetCount - assetIndex); + + return _buildRow(firstAssetIndex + assetIndex, numberOfAssets); + } + + Widget _buildRow(int assetIndex, int count) => Consumer( + builder: (ctx, ref, _) { + final isScrubbing = + ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); + final timelineService = ref.read(timelineServiceProvider); + + // Timeline is being scrubbed, show placeholders + if (isScrubbing) { + return SegmentBuilder.buildPlaceholder( + ctx, + count, + size: Size.square(tileHeight), + spacing: spacing, + ); + } + + // Bucket is already loaded, show the assets + if (timelineService.hasRange(assetIndex, count)) { + final assets = timelineService.getAssets(assetIndex, count); + return _buildAssetRow(ctx, assets); + } + + // Bucket is not loaded, show placeholders and load the bucket + return FutureBuilder( + future: timelineService.loadAssets(assetIndex, count), + builder: (ctxx, snap) { + if (snap.connectionState != ConnectionState.done) { + return SegmentBuilder.buildPlaceholder( + ctx, + count, + size: Size.square(tileHeight), + spacing: spacing, + ); + } + + return _buildAssetRow(ctxx, snap.requireData); + }, + ); + }, + ); + + Widget _buildAssetRow(BuildContext context, List assets) => + FixedTimelineRow( + dimension: tileHeight, + spacing: spacing, + textDirection: Directionality.of(context), + children: List.generate( + assets.length, + (i) => RepaintBoundary(child: ThumbnailTile(assets[i])), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart new file mode 100644 index 0000000000..327e690267 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -0,0 +1,75 @@ +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; + +class FixedSegmentBuilder extends SegmentBuilder { + final double tileHeight; + final int columnCount; + + const FixedSegmentBuilder({ + required super.buckets, + required this.tileHeight, + required this.columnCount, + super.spacing, + super.groupBy, + }); + + List generate() { + final segments = []; + int firstIndex = 0; + double startOffset = 0; + int assetIndex = 0; + DateTime? previousDate; + + for (int i = 0; i < buckets.length; i++) { + final bucket = buckets[i]; + + final assetCount = bucket.assetCount; + final numberOfRows = (assetCount / columnCount).ceil(); + final segmentCount = numberOfRows + 1; + + final segmentFirstIndex = firstIndex; + firstIndex += segmentCount; + final segmentLastIndex = firstIndex - 1; + + final timelineHeader = switch (groupBy) { + GroupAssetsBy.month => HeaderType.month, + GroupAssetsBy.day => + bucket is TimeBucket && bucket.date.month != previousDate?.month + ? HeaderType.monthAndDay + : HeaderType.day, + GroupAssetsBy.none => HeaderType.none, + }; + final headerExtent = SegmentBuilder.headerExtent(timelineHeader); + + final segmentStartOffset = startOffset; + startOffset += headerExtent + + (tileHeight * numberOfRows) + + spacing * (numberOfRows - 1); + final segmentEndOffset = startOffset; + + segments.add( + FixedSegment( + firstIndex: segmentFirstIndex, + lastIndex: segmentLastIndex, + startOffset: segmentStartOffset, + endOffset: segmentEndOffset, + firstAssetIndex: assetIndex, + bucket: bucket, + tileHeight: tileHeight, + columnCount: columnCount, + headerExtent: headerExtent, + spacing: spacing, + header: timelineHeader, + ), + ); + + assetIndex += assetCount; + if (bucket is TimeBucket) { + previousDate = bucket.date; + } + } + return segments; + } +} diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart new file mode 100644 index 0000000000..f5cce1dbbb --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -0,0 +1,60 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class TimelineHeader extends StatelessWidget { + final Bucket bucket; + final HeaderType header; + final double height; + + const TimelineHeader({ + super.key, + required this.bucket, + required this.header, + required this.height, + }); + + String _formatMonth(BuildContext context, DateTime date) { + final formatter = date.year == DateTime.now().year + ? DateFormat.MMMM(context.locale.toLanguageTag()) + : DateFormat.yMMMM(context.locale.toLanguageTag()); + return formatter.format(date); + } + + String _formatDay(BuildContext context, DateTime date) { + final formatter = DateFormat.yMMMEd(context.locale.toLanguageTag()); + return formatter.format(date); + } + + @override + Widget build(BuildContext context) { + if (bucket is! TimeBucket || header == HeaderType.none) { + return const SizedBox.shrink(); + } + + final date = (bucket as TimeBucket).date; + return Container( + padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10), + height: height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (header == HeaderType.month || header == HeaderType.monthAndDay) + Text( + _formatMonth(context, date), + style: context.textTheme.labelLarge + ?.copyWith(fontSize: 24, fontWeight: FontWeight.w500), + ), + if (header == HeaderType.day || header == HeaderType.monthAndDay) + Text( + _formatDay(context, date), + style: context.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart new file mode 100644 index 0000000000..d68d9cfd67 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -0,0 +1,455 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:intl/intl.dart' hide TextDirection; + +/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged +/// for quick navigation of the BoxScrollView. +class Scrubber extends StatefulWidget { + /// The view that will be scrolled with the scroll thumb + final CustomScrollView child; + + /// The segments of the timeline + final List layoutSegments; + + final double timelineHeight; + + final double topPadding; + + final double bottomPadding; + + Scrubber({ + super.key, + Key? scrollThumbKey, + required this.layoutSegments, + required this.timelineHeight, + this.topPadding = 0, + this.bottomPadding = 0, + required this.child, + }) : assert(child.scrollDirection == Axis.vertical); + + @override + State createState() => ScrubberState(); +} + +List<_Segment> _buildSegments({ + required List layoutSegments, + required double timelineHeight, +}) { + final segments = <_Segment>[]; + if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) { + return []; + } + + final formatter = DateFormat.yMMM(); + for (final layoutSegment in layoutSegments) { + final scrollPercentage = + layoutSegment.startOffset / layoutSegments.last.endOffset; + final startOffset = scrollPercentage * timelineHeight; + + final date = (layoutSegment.bucket as TimeBucket).date; + final label = formatter.format(date); + + segments.add( + _Segment( + date: date, + startOffset: startOffset, + scrollLabel: label, + ), + ); + } + + return segments; +} + +class ScrubberState extends State with TickerProviderStateMixin { + double _thumbTopOffset = 0.0; + bool _isDragging = false; + List<_Segment> _segments = []; + + late AnimationController _thumbAnimationController; + Timer? _fadeOutTimer; + late Animation _thumbAnimation; + + late AnimationController _labelAnimationController; + late Animation _labelAnimation; + + double get _scrubberHeight => + widget.timelineHeight - widget.topPadding - widget.bottomPadding; + + late final ScrollController _scrollController; + + double get _currentOffset => + _scrollController.offset * + _scrubberHeight / + _scrollController.position.maxScrollExtent; + + @override + void initState() { + super.initState(); + _isDragging = false; + _segments = _buildSegments( + layoutSegments: widget.layoutSegments, + timelineHeight: _scrubberHeight, + ); + _thumbAnimationController = AnimationController( + vsync: this, + duration: kTimelineScrubberFadeInDuration, + ); + _thumbAnimation = CurvedAnimation( + parent: _thumbAnimationController, + curve: Curves.fastEaseInToSlowEaseOut, + ); + _labelAnimationController = AnimationController( + vsync: this, + duration: kTimelineScrubberFadeInDuration, + ); + + _labelAnimation = CurvedAnimation( + parent: _labelAnimationController, + curve: Curves.fastOutSlowIn, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollController = PrimaryScrollController.of(context); + } + + @override + void didUpdateWidget(covariant Scrubber oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.layoutSegments.lastOrNull?.endOffset != + widget.layoutSegments.lastOrNull?.endOffset) { + _segments = _buildSegments( + layoutSegments: widget.layoutSegments, + timelineHeight: _scrubberHeight, + ); + } + } + + @override + void dispose() { + _thumbAnimationController.dispose(); + _labelAnimationController.dispose(); + _fadeOutTimer?.cancel(); + super.dispose(); + } + + void _resetThumbTimer() { + _fadeOutTimer?.cancel(); + _fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () { + _thumbAnimationController.reverse(); + _fadeOutTimer = null; + }); + } + + bool _onScrollNotification(ScrollNotification notification) { + if (_isDragging) { + // If the user is dragging the thumb, we don't want to update the position + return false; + } + + setState(() { + if (notification is ScrollUpdateNotification) { + _thumbTopOffset = _currentOffset; + if (_labelAnimation.status != AnimationStatus.reverse) { + _labelAnimationController.reverse(); + } + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } + } + _resetThumbTimer(); + }); + + return false; + } + + void _onDragStart(WidgetRef ref) { + ref.read(timelineStateProvider.notifier).setScrubbing(true); + setState(() { + _isDragging = true; + _labelAnimationController.forward(); + _fadeOutTimer?.cancel(); + }); + } + + void _onDragUpdate(DragUpdateDetails details) { + if (!_isDragging) { + return; + } + + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } + + final newOffset = + details.globalPosition.dy - widget.topPadding - widget.bottomPadding; + + setState(() { + _thumbTopOffset = newOffset.clamp(0, _scrubberHeight); + final scrollPercentage = _thumbTopOffset / _scrubberHeight; + final maxScrollExtent = _scrollController.position.maxScrollExtent; + _scrollController.jumpTo(maxScrollExtent * scrollPercentage); + }); + } + + void _onDragEnd(WidgetRef ref) { + ref.read(timelineStateProvider.notifier).setScrubbing(false); + _labelAnimationController.reverse(); + _isDragging = false; + _resetThumbTimer(); + } + + @override + Widget build(BuildContext ctx) { + Text? label; + if (_scrollController.hasClients) { + // Cache to avoid multiple calls to [_currentOffset] + final scrollOffset = _currentOffset; + final labelText = _segments + .lastWhereOrNull( + (segment) => segment.startOffset <= scrollOffset, + ) + ?.scrollLabel ?? + _segments.firstOrNull?.scrollLabel; + label = labelText != null + ? Text( + labelText, + style: ctx.textTheme.bodyLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ) + : null; + } + + return NotificationListener( + onNotification: _onScrollNotification, + child: Stack( + children: [ + RepaintBoundary(child: widget.child), + PositionedDirectional( + top: _thumbTopOffset + widget.topPadding, + end: 0, + child: Consumer( + builder: (_, ref, child) => GestureDetector( + onVerticalDragStart: (_) => _onDragStart(ref), + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: (_) => _onDragEnd(ref), + child: child, + ), + child: _Scrubber( + thumbAnimation: _thumbAnimation, + labelAnimation: _labelAnimation, + label: label, + ), + ), + ), + ], + ), + ); + } +} + +class _ScrollLabel extends StatelessWidget { + final Text label; + final Color backgroundColor; + final Animation animation; + + const _ScrollLabel({ + required this.label, + required this.backgroundColor, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: FadeTransition( + opacity: animation, + child: Container( + margin: const EdgeInsets.only(right: 12.0), + child: Material( + elevation: 4.0, + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + child: Container( + constraints: const BoxConstraints(maxHeight: 28), + padding: const EdgeInsets.symmetric(horizontal: 10.0), + alignment: Alignment.center, + child: label, + ), + ), + ), + ), + ); + } +} + +class _Scrubber extends StatelessWidget { + final Text? label; + final Animation thumbAnimation; + final Animation labelAnimation; + + const _Scrubber({ + this.label, + required this.thumbAnimation, + required this.labelAnimation, + }); + + @override + Widget build(BuildContext context) { + final backgroundColor = context.isDarkTheme + ? context.colorScheme.primary.darken(amount: .5) + : context.colorScheme.primary; + + return _SlideFadeTransition( + animation: thumbAnimation, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (label != null) + _ScrollLabel( + label: label!, + backgroundColor: backgroundColor, + animation: labelAnimation, + ), + _CircularThumb(backgroundColor), + ], + ), + ); + } +} + +class _CircularThumb extends StatelessWidget { + final Color backgroundColor; + + const _CircularThumb(this.backgroundColor); + + @override + Widget build(BuildContext context) { + return CustomPaint( + foregroundPainter: const _ArrowPainter(Colors.white), + child: Material( + elevation: 4.0, + color: backgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(48.0), + bottomLeft: Radius.circular(48.0), + topRight: Radius.circular(4.0), + bottomRight: Radius.circular(4.0), + ), + child: Container( + constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0)), + ), + ), + ); + } +} + +class _ArrowPainter extends CustomPainter { + final Color color; + + const _ArrowPainter(this.color); + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color; + const width = 12.0; + const height = 8.0; + final baseX = size.width / 2; + final baseY = size.height / 2; + + canvas.drawPath( + _trianglePath(Offset(baseX, baseY - 2.0), width, height, true), + paint, + ); + canvas.drawPath( + _trianglePath(Offset(baseX, baseY + 2.0), width, height, false), + paint, + ); + } + + static Path _trianglePath(Offset o, double width, double height, bool isUp) { + return Path() + ..moveTo(o.dx, o.dy) + ..lineTo(o.dx + width, o.dy) + ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height) + ..close(); + } +} + +class _SlideFadeTransition extends StatelessWidget { + final Animation _animation; + final Widget _child; + + const _SlideFadeTransition({ + required Animation animation, + required Widget child, + }) : _animation = animation, + _child = child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) => + _animation.value == 0.0 ? const SizedBox() : child!, + child: SlideTransition( + position: Tween( + begin: const Offset(0.3, 0.0), + end: const Offset(0.0, 0.0), + ).animate(_animation), + child: FadeTransition( + opacity: _animation, + child: _child, + ), + ), + ); + } +} + +class _Segment { + final DateTime date; + final double startOffset; + final String scrollLabel; + + const _Segment({ + required this.date, + required this.startOffset, + required this.scrollLabel, + }); + + _Segment copyWith({ + DateTime? date, + double? startOffset, + String? scrollLabel, + }) { + return _Segment( + date: date ?? this.date, + startOffset: startOffset ?? this.startOffset, + scrollLabel: scrollLabel ?? this.scrollLabel, + ); + } + + @override + String toString() { + return 'Segment(scrollLabel: $scrollLabel, date: $date)'; + } +} diff --git a/mobile/lib/presentation/widgets/timeline/segment.model.dart b/mobile/lib/presentation/widgets/timeline/segment.model.dart new file mode 100644 index 0000000000..09d892f69a --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/segment.model.dart @@ -0,0 +1,100 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; + +// Segments are the time groups buckets in the timeline view. +// Each segment contains a header and a list of asset rows. +abstract class Segment { + // The index of the first row of the segment, usually the header, but if not, it can be any asset. + final int firstIndex; + // The index of the last asset of the segment. + final int lastIndex; + // The offset of the first row from beginning of the timeline. + final double startOffset; + // The offset of the last row from beginning of the timeline. + final double endOffset; + // The spacing between the header and the first row of the segment. + final double spacing; + final double headerExtent; + // the start index of the asset of this segment from the beginning of the timeline. + final int firstAssetIndex; + final Bucket bucket; + + // The index of the row after the header + final int gridIndex; + // The offset of the row after the header + final double gridOffset; + // The type of the header + final HeaderType header; + + const Segment({ + required this.firstIndex, + required this.lastIndex, + required this.startOffset, + required this.endOffset, + required this.firstAssetIndex, + required this.bucket, + required this.headerExtent, + required this.spacing, + required this.header, + }) : gridIndex = firstIndex + 1, + gridOffset = startOffset + headerExtent + spacing; + + bool containsIndex(int index) => firstIndex <= index && index <= lastIndex; + + bool isWithinOffset(double offset) => + startOffset <= offset && offset <= endOffset; + + int getMinChildIndexForScrollOffset(double scrollOffset); + int getMaxChildIndexForScrollOffset(double scrollOffset); + double indexToLayoutOffset(int index); + + Widget builder(BuildContext context, int index); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Segment && + other.firstIndex == firstIndex && + other.lastIndex == lastIndex && + other.startOffset == startOffset && + other.endOffset == endOffset && + other.spacing == spacing && + other.firstAssetIndex == firstAssetIndex && + other.headerExtent == headerExtent && + other.gridIndex == gridIndex && + other.gridOffset == gridOffset && + other.header == header; + } + + @override + int get hashCode => + firstIndex.hashCode ^ + lastIndex.hashCode ^ + startOffset.hashCode ^ + endOffset.hashCode ^ + spacing.hashCode ^ + headerExtent.hashCode ^ + firstAssetIndex.hashCode ^ + gridIndex.hashCode ^ + gridOffset.hashCode ^ + header.hashCode; + + @override + String toString() { + return 'Segment(firstIndex: $firstIndex, lastIndex: $lastIndex)'; + } +} + +extension SegmentListExtension on List { + bool equals(List other) => + length == other.length && + lastOrNull?.endOffset == other.lastOrNull?.endOffset; + + Segment? findByIndex(int index) => + firstWhereOrNull((s) => s.containsIndex(index)); + + Segment? findByOffset(double offset) => + firstWhereOrNull((s) => s.isWithinOffset(offset)) ?? lastOrNull; +} diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart new file mode 100644 index 0000000000..97031c623f --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; + +abstract class SegmentBuilder { + final List buckets; + final double spacing; + final GroupAssetsBy groupBy; + + const SegmentBuilder({ + required this.buckets, + this.spacing = kTimelineSpacing, + this.groupBy = GroupAssetsBy.day, + }); + + static double headerExtent(HeaderType header) { + switch (header) { + case HeaderType.month: + return kTimelineHeaderExtent; + case HeaderType.day: + return kTimelineHeaderExtent * 0.90; + case HeaderType.monthAndDay: + return kTimelineHeaderExtent * 1.5; + case HeaderType.none: + return 0.0; + } + } + + static Widget buildPlaceholder( + BuildContext context, + int count, { + Size size = const Size.square(kTimelineFixedTileExtent), + double spacing = kTimelineSpacing, + }) => + RepaintBoundary( + child: FixedTimelineRow( + dimension: size.height, + spacing: spacing, + textDirection: Directionality.of(context), + children: List.generate( + count, + (_) => ThumbnailPlaceholder(width: size.width, height: size.height), + ), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart new file mode 100644 index 0000000000..6e38bf2ac1 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -0,0 +1,100 @@ +import 'dart:math' as math; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class TimelineArgs { + final double maxWidth; + final double maxHeight; + final double spacing; + final int columnCount; + + const TimelineArgs({ + required this.maxWidth, + required this.maxHeight, + this.spacing = kTimelineSpacing, + this.columnCount = kTimelineColumnCount, + }); + + @override + bool operator ==(covariant TimelineArgs other) { + return spacing == other.spacing && + maxWidth == other.maxWidth && + maxHeight == other.maxHeight && + columnCount == other.columnCount; + } + + @override + int get hashCode => + maxWidth.hashCode ^ + maxHeight.hashCode ^ + spacing.hashCode ^ + columnCount.hashCode; +} + +class TimelineState { + final bool isScrubbing; + + const TimelineState({this.isScrubbing = false}); + + @override + bool operator ==(covariant TimelineState other) { + return isScrubbing == other.isScrubbing; + } + + @override + int get hashCode => isScrubbing.hashCode; + + TimelineState copyWith({bool? isScrubbing}) { + return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing); + } +} + +class TimelineStateNotifier extends Notifier { + TimelineStateNotifier(); + + void setScrubbing(bool isScrubbing) { + state = state.copyWith(isScrubbing: isScrubbing); + } + + @override + TimelineState build() => const TimelineState(isScrubbing: false); +} + +// This provider watches the buckets from the timeline service & args and serves the segments. +// It should be used only after the timeline service and timeline args provider is overridden +final timelineSegmentProvider = StreamProvider.autoDispose>( + (ref) async* { + final args = ref.watch(timelineArgsProvider); + final columnCount = args.columnCount; + final spacing = args.spacing; + final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); + final tileExtent = math.max(0, availableTileWidth) / columnCount; + + final groupBy = GroupAssetsBy + .values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; + + final timelineService = ref.watch(timelineServiceProvider); + yield* timelineService.watchBuckets().map((buckets) { + return FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileExtent, + columnCount: columnCount, + spacing: spacing, + groupBy: groupBy, + ).generate(); + }); + }, + dependencies: [timelineServiceProvider, timelineArgsProvider], +); + +final timelineStateProvider = + NotifierProvider( + TimelineStateNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart new file mode 100644 index 0000000000..6ea3ddaf44 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -0,0 +1,365 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class Timeline extends StatelessWidget { + const Timeline({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: LayoutBuilder( + builder: (_, constraints) => ProviderScope( + overrides: [ + timelineArgsProvider.overrideWith( + (ref) => TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: ref.watch( + settingsProvider.select((s) => s.get(Setting.tilesPerRow)), + ), + ), + ), + ], + child: const _SliverTimeline(), + ), + ), + ); + } +} + +class _SliverTimeline extends StatefulWidget { + const _SliverTimeline(); + + @override + State createState() => _SliverTimelineState(); +} + +class _SliverTimelineState extends State<_SliverTimeline> { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext _) { + return Consumer( + builder: (context, ref, child) { + final asyncSegments = ref.watch(timelineSegmentProvider); + final maxHeight = + ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + return asyncSegments.widgetWhen( + onData: (segments) { + final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + + return PrimaryScrollController( + controller: _scrollController, + child: Scrubber( + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: context.padding.top + 10, + bottomPadding: context.padding.bottom + 10, + child: CustomScrollView( + primary: true, + cacheExtent: maxHeight * 2, + slivers: [ + _SliverSegmentedList( + segments: segments, + delegate: SliverChildBuilderDelegate( + (ctx, index) { + if (index >= childCount) return null; + final segment = segments.findByIndex(index); + return segment?.builder(ctx, index) ?? + const SizedBox.shrink(); + }, + childCount: childCount, + addAutomaticKeepAlives: false, + // We add repaint boundary around tiles, so skip the auto boundaries + addRepaintBoundaries: false, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget { + final List _segments; + + const _SliverSegmentedList({ + required List segments, + required super.delegate, + }) : _segments = segments; + + @override + _RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) => + _RenderSliverTimelineBoxAdaptor( + childManager: context as SliverMultiBoxAdaptorElement, + segments: _segments, + ); + + @override + void updateRenderObject( + BuildContext context, + _RenderSliverTimelineBoxAdaptor renderObject, + ) { + renderObject.segments = _segments; + } +} + +/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets +class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor { + List _segments; + + set segments(List updatedSegments) { + if (_segments.equals(updatedSegments)) { + return; + } + _segments = updatedSegments; + markNeedsLayout(); + } + + _RenderSliverTimelineBoxAdaptor({ + required super.childManager, + required List segments, + }) : _segments = segments; + + int getMinChildIndexForScrollOffset(double offset) => + _segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? + 0; + + int getMaxChildIndexForScrollOffset(double offset) => + _segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ?? + 0; + + double indexToLayoutOffset(int index) => + (_segments.findByIndex(index) ?? _segments.lastOrNull) + ?.indexToLayoutOffset(index) ?? + 0; + + double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0; + + double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0; + + @override + void performLayout() { + childManager.didStartLayout(); + // Assume initially that we have enough children to fill the viewport/cache area. + childManager.setDidUnderflow(false); + + final double scrollOffset = + constraints.scrollOffset + constraints.cacheOrigin; + assert(scrollOffset >= 0.0); + + final double remainingExtent = constraints.remainingCacheExtent; + assert(remainingExtent >= 0.0); + + final double targetScrollOffset = scrollOffset + remainingExtent; + + // Find the index of the first child that should be visible or in the leading cache area. + final int firstRequiredChildIndex = + getMinChildIndexForScrollOffset(scrollOffset); + + // Find the index of the last child that should be visible or in the trailing cache area. + final int? lastRequiredChildIndex = targetScrollOffset.isFinite + ? getMaxChildIndexForScrollOffset(targetScrollOffset) + : null; + + // Remove children that are no longer visible or within the cache area. + if (firstChild == null) { + collectGarbage(0, 0); + } else { + final int leadingChildrenToRemove = + calculateLeadingGarbage(firstIndex: firstRequiredChildIndex); + final int trailingChildrenToRemove = lastRequiredChildIndex == null + ? 0 + : calculateTrailingGarbage(lastIndex: lastRequiredChildIndex); + collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove); + } + + // If there are currently no children laid out (e.g., initial load), + // try to add the first child needed for the current scroll offset. + if (firstChild == null) { + final double firstChildLayoutOffset = + indexToLayoutOffset(firstRequiredChildIndex); + final bool childAdded = addInitialChild( + index: firstRequiredChildIndex, + layoutOffset: firstChildLayoutOffset, + ); + + if (!childAdded) { + // There are either no children, or we are past the end of all our children. + final double max = + firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset(); + geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max); + childManager.didFinishLayout(); + return; + } + } + + // Layout children that might have scrolled into view from the top (before the current firstChild). + RenderBox? highestLaidOutChild; + final childConstraints = constraints.asBoxConstraints(); + + for (int currentIndex = indexOf(firstChild!) - 1; + currentIndex >= firstRequiredChildIndex; + --currentIndex) { + final RenderBox? newLeadingChild = + insertAndLayoutLeadingChild(childConstraints); + if (newLeadingChild == null) { + // If a child is missing where we expect one, it indicates + // an inconsistency in offset that needs correction. + final Segment? segment = + _segments.findByIndex(currentIndex) ?? _segments.firstOrNull; + geometry = SliverGeometry( + // Request a scroll correction based on where the missing child should have been. + scrollOffsetCorrection: + segment?.indexToLayoutOffset(currentIndex) ?? 0.0, + ); + // Parent will re-layout everything. + return; + } + final childParentData = + newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(currentIndex); + assert(childParentData.index == currentIndex); + highestLaidOutChild ??= newLeadingChild; + } + + // If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]), + // or even if it did, we need to ensure the first visible child is correctly laid out + // and establish our starting point for laying out trailing children. + + // If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children. + // The [firstChild] that existed at the start of performLayout is still the first one we need. + if (highestLaidOutChild == null) { + firstChild!.layout(childConstraints); + final childParentData = + firstChild!.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = + indexToLayoutOffset(firstRequiredChildIndex); + highestLaidOutChild = firstChild; + } + + RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild; + + // Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children + // until we reach the [lastRequiredChildIndex] or run out of children. + double calculatedMaxScrollOffset = double.infinity; + + for (int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1; + lastRequiredChildIndex == null || + currentIndex <= lastRequiredChildIndex; + ++currentIndex) { + RenderBox? child = childAfter(mostRecentlyLaidOutChild!); + + if (child == null || indexOf(child) != currentIndex) { + child = insertAndLayoutChild( + childConstraints, + after: mostRecentlyLaidOutChild, + ); + if (child == null) { + final Segment? segment = + _segments.findByIndex(currentIndex) ?? _segments.lastOrNull; + calculatedMaxScrollOffset = + segment?.indexToLayoutOffset(currentIndex) ?? + computeMaxScrollOffset(); + break; + } + } else { + child.layout(childConstraints); + } + + mostRecentlyLaidOutChild = child; + final childParentData = mostRecentlyLaidOutChild.parentData! + as SliverMultiBoxAdaptorParentData; + assert(childParentData.index == currentIndex); + childParentData.layoutOffset = indexToLayoutOffset(currentIndex); + } + + final int lastLaidOutChildIndex = indexOf(lastChild!); + final double leadingScrollOffset = + indexToLayoutOffset(firstRequiredChildIndex); + final double trailingScrollOffset = + indexToLayoutOffset(lastLaidOutChildIndex + 1); + + assert( + firstRequiredChildIndex == 0 || + (childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <= + precisionErrorTolerance, + ); + assert(debugAssertChildListIsNonEmptyAndContiguous()); + assert(indexOf(firstChild!) == firstRequiredChildIndex); + assert( + lastRequiredChildIndex == null || + lastLaidOutChildIndex <= lastRequiredChildIndex, + ); + + calculatedMaxScrollOffset = math.min( + calculatedMaxScrollOffset, + estimateMaxScrollOffset(), + ); + + final double paintExtent = calculatePaintOffset( + constraints, + from: leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double cacheExtent = calculateCacheOffset( + constraints, + from: leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double targetEndScrollOffsetForPaint = + constraints.scrollOffset + constraints.remainingPaintExtent; + final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite + ? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint) + : null; + + final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset); + + geometry = SliverGeometry( + scrollExtent: calculatedMaxScrollOffset, + paintExtent: paintExtent, + maxPaintExtent: maxPaintExtent, + // Indicates if there's content scrolled off-screen. + // This is true if the last child needed for painting is actually laid out, + // or if the first child is partially visible. + hasVisualOverflow: (targetLastIndexForPaint != null && + lastLaidOutChildIndex >= targetLastIndexForPaint) || + constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent, + ); + + // We may have started the layout while scrolled to the end, which would not + // expose a new child. + if (calculatedMaxScrollOffset == trailingScrollOffset) { + childManager.setDidUnderflow(true); + } + + childManager.didFinishLayout(); + } +} diff --git a/mobile/lib/providers/image/cache/image_loader.dart b/mobile/lib/providers/image/cache/image_loader.dart index 6e83e9af64..fd6e567b2c 100644 --- a/mobile/lib/providers/image/cache/image_loader.dart +++ b/mobile/lib/providers/image/cache/image_loader.dart @@ -37,8 +37,7 @@ class ImageLoader { } else if (result is FileInfo) { // We have the file final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); - final decoded = await decode(buffer); - return decoded; + return decode(buffer); } } diff --git a/mobile/lib/providers/infrastructure/setting.provider.dart b/mobile/lib/providers/infrastructure/setting.provider.dart new file mode 100644 index 0000000000..ad0af8282e --- /dev/null +++ b/mobile/lib/providers/infrastructure/setting.provider.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; + +class SettingsNotifier extends Notifier { + @override + SettingsService build() => + SettingsService(storeService: ref.read(storeServiceProvider)); + + T get(Setting setting) => state.get(setting); + + Future set(Setting setting, T value) async { + await state.set(setting, value); + ref.invalidateSelf(); + } + + Stream watch(Setting setting) => state.watch(setting); +} + +final settingsProvider = + NotifierProvider(SettingsNotifier.new); diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart new file mode 100644 index 0000000000..7004dd0262 --- /dev/null +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; + +final timelineRepositoryProvider = Provider( + (ref) => DriftTimelineRepository(ref.watch(driftProvider)), +); + +final timelineArgsProvider = Provider.autoDispose( + (ref) => + throw UnimplementedError('Will be overridden through a ProviderScope.'), +); + +final timelineServiceProvider = Provider.autoDispose( + (ref) => + throw UnimplementedError('Will be overridden through a ProviderScope.'), +); + +final timelineFactoryProvider = Provider( + (ref) => TimelineFactory( + timelineRepository: ref.watch(timelineRepositoryProvider), + settingsService: ref.watch(settingsProvider), + ), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 46de68529c..46d68e6422 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -66,6 +66,8 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -340,5 +342,13 @@ class AppRouter extends RootStackRouter { page: ExpBackupAlbumSelectionRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: LocalTimelineRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: MainTimelineRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 81b8171762..c3da41e0e4 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -903,6 +903,43 @@ class LocalMediaSummaryRoute extends PageRouteInfo { ); } +/// generated route for +/// [LocalTimelinePage] +class LocalTimelineRoute extends PageRouteInfo { + LocalTimelineRoute({ + Key? key, + required String albumId, + List? children, + }) : super( + LocalTimelineRoute.name, + args: LocalTimelineRouteArgs(key: key, albumId: albumId), + initialChildren: children, + ); + + static const String name = 'LocalTimelineRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return LocalTimelinePage(key: args.key, albumId: args.albumId); + }, + ); +} + +class LocalTimelineRouteArgs { + const LocalTimelineRouteArgs({this.key, required this.albumId}); + + final Key? key; + + final String albumId; + + @override + String toString() { + return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}'; + } +} + /// generated route for /// [LockedPage] class LockedRoute extends PageRouteInfo { @@ -935,6 +972,22 @@ class LoginRoute extends PageRouteInfo { ); } +/// generated route for +/// [MainTimelinePage] +class MainTimelineRoute extends PageRouteInfo { + const MainTimelineRoute({List? children}) + : super(MainTimelineRoute.name, initialChildren: children); + + static const String name = 'MainTimelineRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const MainTimelinePage(); + }, + ); +} + /// generated route for /// [MapLocationPickerPage] class MapLocationPickerRoute extends PageRouteInfo { diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index d6c44278c7..ab0e685778 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,10 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:logging/logging.dart'; final memoryServiceProvider = StateProvider((ref) { @@ -40,7 +40,11 @@ class MemoryService { .getAllByRemoteId(memory.assets.map((e) => e.id)); final yearsAgo = now.year - memory.data.year; if (dbAssets.isNotEmpty) { - final String title = t('years_ago', {'years': yearsAgo.toString()}); + final String title = 'years_ago'.t( + args: { + 'years': yearsAgo.toString(), + }, + ); memories.add( Memory( title: title, diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index e22076aae9..a5466c83a2 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -6,10 +6,10 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -59,10 +59,11 @@ Future handleArchiveAssets( await ref .read(assetProvider.notifier) .toggleArchive(selection, shouldArchive); - final message = shouldArchive - ? t('moved_to_archive', {'count': selection.length}) - : t('moved_to_library', {'count': selection.length}); + ? 'moved_to_archive' + .t(context: context, args: {'count': selection.length}) + : 'moved_to_library' + .t(context: context, args: {'count': selection.length}); if (context.mounted) { ImmichToast.show( context: context, diff --git a/mobile/lib/utils/thumbnail_utils.dart b/mobile/lib/utils/thumbnail_utils.dart index 9681815fde..33dd916980 100644 --- a/mobile/lib/utils/thumbnail_utils.dart +++ b/mobile/lib/utils/thumbnail_utils.dart @@ -1,7 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/utils/translation.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; String getAltText( ExifInfo? exifInfo, @@ -14,7 +14,7 @@ String getAltText( } final (template, args) = getAltTextTemplate(exifInfo, fileCreatedAt, type, peopleNames); - return t(template, args); + return template.t(args: args); } (String, Map) getAltTextTemplate( diff --git a/mobile/lib/utils/translation.dart b/mobile/lib/utils/translation.dart deleted file mode 100644 index 1a33161dbc..0000000000 --- a/mobile/lib/utils/translation.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:intl/message_format.dart'; - -String t(String key, [Map? args]) { - try { - String message = key.tr(); - if (args != null) { - return MessageFormat(message, locale: Intl.defaultLocale ?? 'en') - .format(args); - } - return message; - } catch (e) { - return key; - } -} diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index 9f78b6066d..5e89cd7db3 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; class AlbumThumbnailCard extends ConsumerWidget { @@ -62,7 +62,12 @@ class AlbumThumbnailCard extends ConsumerWidget { if (album.ownerId == ref.read(currentUserProvider)?.id) { owner = 'owned'.tr(); } else if (album.ownerName != null) { - owner = t('shared_by_user', {'user': album.ownerName!}); + owner = 'shared_by_user'.t( + context: context, + args: { + 'user': album.ownerName!, + }, + ); } } @@ -70,7 +75,12 @@ class AlbumThumbnailCard extends ConsumerWidget { TextSpan( children: [ TextSpan( - text: t('items_count', {'count': album.assetCount}), + text: 'items_count'.t( + context: context, + args: { + 'count': album.assetCount, + }, + ), ), if (owner != null) const TextSpan(text: ' • '), if (owner != null) TextSpan(text: owner), diff --git a/mobile/lib/widgets/album/album_thumbnail_listtile.dart b/mobile/lib/widgets/album/album_thumbnail_listtile.dart index 11ef5d329b..f35d4b7ede 100644 --- a/mobile/lib/widgets/album/album_thumbnail_listtile.dart +++ b/mobile/lib/widgets/album/album_thumbnail_listtile.dart @@ -4,10 +4,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:openapi/api.dart'; class AlbumThumbnailListTile extends StatelessWidget { @@ -91,7 +91,12 @@ class AlbumThumbnailListTile extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - t('items_count', {'count': album.assetCount}), + 'items_count'.t( + context: context, + args: { + 'count': album.assetCount, + }, + ), style: const TextStyle( fontSize: 12, ), diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 9904447569..98b1c6f601 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; @@ -24,7 +25,6 @@ import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; -import 'package:immich_mobile/utils/translation.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; @@ -257,13 +257,19 @@ class MultiselectGrid extends HookConsumerWidget { final failedCount = totalCount - successCount; final msg = failedCount > 0 - ? t('assets_downloaded_failed', { - 'count': successCount, - 'error': failedCount, - }) - : t('assets_downloaded_successfully', { - 'count': successCount, - }); + ? 'assets_downloaded_failed'.t( + context: context, + args: { + 'count': successCount, + 'error': failedCount, + }, + ) + : 'assets_downloaded_successfully'.t( + context: context, + args: { + 'count': successCount, + }, + ); ImmichToast.show( context: context, diff --git a/mobile/lib/widgets/backup/ios_debug_info_tile.dart b/mobile/lib/widgets/backup/ios_debug_info_tile.dart index 04be0c00dc..de80b3bfd1 100644 --- a/mobile/lib/widgets/backup/ios_debug_info_tile.dart +++ b/mobile/lib/widgets/backup/ios_debug_info_tile.dart @@ -1,9 +1,9 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; -import 'package:immich_mobile/utils/translation.dart'; /// This is a simple debug widget which should be removed later on when we are /// more confident about background sync @@ -22,29 +22,28 @@ class IosDebugInfoTile extends HookConsumerWidget { final String title; if (processes == 0) { - title = 'ios_debug_info_no_processes_queued'.tr(); + title = 'ios_debug_info_no_processes_queued'.t(context: context); } else { - title = t('ios_debug_info_processes_queued', {'count': processes}); + title = 'ios_debug_info_processes_queued' + .t(context: context, args: {'count': processes}); } final df = DateFormat.yMd().add_jm(); final String subtitle; if (fetch == null && processing == null) { - subtitle = 'ios_debug_info_no_sync_yet'.tr(); + subtitle = 'ios_debug_info_no_sync_yet'.t(context: context); } else if (fetch != null && processing == null) { - subtitle = - t('ios_debug_info_fetch_ran_at', {'dateTime': df.format(fetch)}); + subtitle = 'ios_debug_info_fetch_ran_at' + .t(context: context, args: {'dateTime': df.format(fetch)}); } else if (processing != null && fetch == null) { - subtitle = t( - 'ios_debug_info_processing_ran_at', - {'dateTime': df.format(processing)}, - ); + subtitle = 'ios_debug_info_processing_ran_at' + .t(context: context, args: {'dateTime': df.format(processing)}); } else { final fetchOrProcessing = fetch!.isAfter(processing!) ? fetch : processing; - subtitle = t( - 'ios_debug_info_last_sync_at', - {'dateTime': df.format(fetchOrProcessing)}, + subtitle = 'ios_debug_info_last_sync_at'.t( + context: context, + args: {'dateTime': df.format(fetchOrProcessing)}, ); } diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index 7dc7f89ea1..4d41d5b19b 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -91,6 +92,7 @@ class LanguageSettings extends HookConsumerWidget { padding: const EdgeInsets.all(8), itemCount: filteredLocaleEntries.value.length, itemExtent: 64.0, + cacheExtent: 100, itemBuilder: (context, index) { final countryName = filteredLocaleEntries.value[index].key; @@ -100,6 +102,7 @@ class LanguageSettings extends HookConsumerWidget { selectedLocale.value == localeValue; return _LanguageItem( + key: ValueKey(localeValue.toString()), countryName: countryName, localeValue: localeValue, isSelected: isSelected, @@ -162,7 +165,7 @@ class _LanguageSearchBar extends StatelessWidget { child: SearchField( autofocus: false, contentPadding: const EdgeInsets.all(12), - hintText: 'language_search_hint'.tr(), + hintText: 'language_search_hint'.t(context: context), prefixIcon: const Icon(Icons.search_rounded), suffixIcon: controller.text.isNotEmpty ? IconButton( @@ -196,14 +199,14 @@ class _LanguageNotFound extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'language_no_results_title'.tr(), + 'language_no_results_title'.t(context: context), style: context.textTheme.titleMedium?.copyWith( color: context.colorScheme.onSurface, ), ), const SizedBox(height: 4), Text( - 'language_no_results_subtitle'.tr(), + 'language_no_results_subtitle'.t(context: context), style: context.textTheme.bodyMedium?.copyWith( color: context.colorScheme.onSurface.withValues(alpha: 0.8), ), @@ -246,7 +249,7 @@ class _LanguageApplyButton extends StatelessWidget { ), ) : Text( - 'setting_languages_apply'.tr(), + 'setting_languages_apply'.t(context: context), style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 16.0, @@ -261,6 +264,7 @@ class _LanguageApplyButton extends StatelessWidget { class _LanguageItem extends StatelessWidget { const _LanguageItem({ + super.key, required this.countryName, required this.localeValue, required this.isSelected, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a02379a6bd..79f2901b7d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1810,7 +1810,7 @@ packages: source: hosted version: "2.1.4" stream_transform: - dependency: transitive + dependency: "direct main" description: name: stream_transform sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 52b04e0d9a..9980622185 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: share_handler: ^0.0.22 share_plus: ^10.1.4 socket_io_client: ^2.0.3+1 + stream_transform: ^2.1.1 thumbhash: 0.1.0+1 timezone: ^0.9.4 url_launcher: ^6.3.1 diff --git a/server/package-lock.json b/server/package-lock.json index 6f40c42fbf..1c3efe249e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -48,6 +48,7 @@ "kysely-postgres-js": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", + "mnemonist": "^0.40.3", "nest-commander": "^3.16.0", "nestjs-cls": "^5.0.0", "nestjs-kysely": "^1.1.0", @@ -473,6 +474,13 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -3174,6 +3182,13 @@ "node": ">= 14" } }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@npmcli/fs": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", @@ -6859,6 +6874,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/archiver-utils/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -7484,6 +7505,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/cacache/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -11837,10 +11865,13 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/luxon": { "version": "3.6.1", @@ -12304,6 +12335,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mnemonist": { + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/mock-fs": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz", @@ -13061,6 +13101,12 @@ "node": ">= 0.4" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -13492,15 +13538,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/path-source": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/path-source/-/path-source-0.1.3.tgz", @@ -16043,6 +16080,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "peer": true + }, "node_modules/sucrase/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -16596,6 +16640,13 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -17196,6 +17247,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/typeorm/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/typeorm/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", diff --git a/server/package.json b/server/package.json index d2fd777be3..e19bf27aaf 100644 --- a/server/package.json +++ b/server/package.json @@ -74,6 +74,7 @@ "kysely-postgres-js": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", + "mnemonist": "^0.40.3", "nest-commander": "^3.16.0", "nestjs-cls": "^5.0.0", "nestjs-kysely": "^1.1.0", diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 229a523c17..6633fb68ac 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -179,9 +179,8 @@ export class PersonRepository { ) .$if(!options?.closestFaceAssetId, (qb) => qb - .orderBy(sql`NULLIF(person.name, '') is null`, 'asc') .orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc') - .orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`) + .orderBy(sql`NULLIF(person.name, '') asc nulls last`) .orderBy('person.createdAt'), ) .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 73678f05af..a10c01e8d3 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { LRUMap } from 'mnemonist'; import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; @@ -24,6 +25,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SearchService extends BaseService { + private embeddingCache = new LRUMap(100); + async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); return people.map((person) => mapPerson(person)); @@ -98,16 +101,21 @@ export class SearchService extends BaseService { throw new BadRequestException('Smart search is not enabled'); } - const userIds = await this.getUserIdsToSearch(auth); - const embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, { - modelName: machineLearning.clip.modelName, - language: dto.language, - }); + const userIds = this.getUserIdsToSearch(auth); + const key = machineLearning.clip.modelName + dto.query + dto.language; + let embedding = this.embeddingCache.get(key); + if (!embedding) { + embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, { + modelName: machineLearning.clip.modelName, + language: dto.language, + }); + this.embeddingCache.set(key, embedding); + } const page = dto.page ?? 1; const size = dto.size || 100; const { hasNextPage, items } = await this.searchRepository.searchSmart( { page, size }, - { ...dto, userIds, embedding }, + { ...dto, userIds: await userIds, embedding }, ); return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); diff --git a/web/.browserslistrc b/web/.browserslistrc index c3a939f1b7..18be2d93e6 100644 --- a/web/.browserslistrc +++ b/web/.browserslistrc @@ -1,5 +1,3 @@ > 0.2% and last 4 major versions > 0.5% not dead -edge >= 135 -not edge < 135 diff --git a/web/eslint.config.js b/web/eslint.config.js index 55d39e637f..6b7b343ad1 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -4,6 +4,7 @@ import eslintPluginCompat from 'eslint-plugin-compat'; import eslintPluginSvelte from 'eslint-plugin-svelte'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import parser from 'svelte-eslint-parser'; @@ -23,7 +24,13 @@ export default typescriptEslint.config( rules: { 'tscompat/tscompat': [ 'error', - { browserslist: ['> 0.2% and last 4 major versions', '> 0.5%', 'not dead', 'edge >= 135', 'not edge < 135'] }, + { + browserslist: fs + .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')), + }, ], }, languageOptions: { diff --git a/web/package-lock.json b/web/package-lock.json index 7a36685cdb..72b9eb6a7c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3656,9 +3656,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", "dev": true, "funding": [ { @@ -3676,10 +3676,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -3752,9 +3752,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001713", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz", - "integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==", + "version": "1.0.30001723", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz", + "integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==", "dev": true, "funding": [ { @@ -4374,9 +4374,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.137", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz", - "integrity": "sha512-/QSJaU2JyIuTbbABAo/crOs+SuAZLS+fVVS10PVrIT9hrRkmZl8Hb0xPSkKRUUWHQtYzXHpQUW3Dy5hwMzGZkA==", + "version": "1.5.167", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.167.tgz", + "integrity": "sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==", "dev": true, "license": "ISC" }, diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte deleted file mode 100644 index 3a20e10602..0000000000 --- a/web/src/lib/components/album-page/album-options.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - -{#if !selectedRemoveUser} - - -
-
-

{$t('settings').toUpperCase()}

-
- {#if order} - - {/if} - -
-
-
-
{$t('people').toUpperCase()}
-
- - -
-
- -
-
{user.name}
-
{$t('owner')}
-
- - {#each album.albumUsers as { user, role } (user.id)} -
-
- -
-
{user.name}
- {#if role === AlbumUserRole.Viewer} - {$t('role_viewer')} - {:else} - {$t('role_editor')} - {/if} - {#if user.id !== album.ownerId} - - {#if role === AlbumUserRole.Viewer} - handleUpdateSharedUserRole(user, AlbumUserRole.Editor)} - text={$t('allow_edits')} - /> - {:else} - handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)} - text={$t('disallow_edits')} - /> - {/if} - - handleMenuRemove(user)} text={$t('remove')} /> - - {/if} -
- {/each} -
-
-
-
-
-{/if} - -{#if selectedRemoveUser} - (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))} - /> -{/if} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte index a264ad8ddd..d61a534ed1 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte @@ -53,12 +53,10 @@ img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash }); - img.addEventListener('load', () => onImageLoad(true)); - img.addEventListener('error', (error) => { - handleError(error, $t('error_loading_image')); - }); + img.addEventListener('load', () => onImageLoad(true), { passive: true }); + img.addEventListener('error', (error) => handleError(error, $t('error_loading_image')), { passive: true }); - globalThis.addEventListener('mousemove', handleMouseMove); + globalThis.addEventListener('mousemove', handleMouseMove, { passive: true }); }); onDestroy(() => { diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts index bce90efd9e..63a42b8b96 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -31,8 +31,8 @@ export function onImageLoad(resetSize: boolean = false) { cropFrameEl?.classList.add('transition'); cropSettings.update((crop) => normalizeCropArea(crop, img, scale)); cropFrameEl?.classList.add('transition'); - cropFrameEl?.addEventListener('transitionend', () => { - cropFrameEl?.classList.remove('transition'); + cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { + passive: true, }); } cropImageScale.set(scale); diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts index b00f5331b2..832f0e4339 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -58,7 +58,7 @@ export function handleMouseDown(e: MouseEvent) { } document.body.style.userSelect = 'none'; - globalThis.addEventListener('mouseup', handleMouseUp); + globalThis.addEventListener('mouseup', handleMouseUp, { passive: true }); } export function handleMouseMove(e: MouseEvent) { diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 517e630dc9..820c7f3fd4 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -7,9 +7,9 @@ type AdapterConstructor, type PluginConstructor, } from '@photo-sphere-viewer/core'; - import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin'; - import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin'; import '@photo-sphere-viewer/core/index.css'; + import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin'; + import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin'; import '@photo-sphere-viewer/settings-plugin/index.css'; import { onDestroy, onMount } from 'svelte'; @@ -68,18 +68,20 @@ fisheye: false, }); const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin; + const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { + // zoomLevel range: [0, 100] + if (Math.round(zoomLevel) >= 75) { + // Replace the preview with the original + void resolutionPlugin.setResolution('original'); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + } + }; if (originalPanorama && !$alwaysLoadOriginalFile) { - const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { - // zoomLevel range: [0, 100] - if (Math.round(zoomLevel) >= 75) { - // Replace the preview with the original - void resolutionPlugin.setResolution('original'); - viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); - } - }; - viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); } + + return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); }); onDestroy(() => { diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 9cb80fb2dd..5b3c84ed5a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -3,9 +3,10 @@ import { zoomImageAction } from '$lib/actions/zoom-image'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; + import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; - import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; @@ -192,8 +193,8 @@ if (loader?.complete) { onload(); } - loader?.addEventListener('load', onload); - loader?.addEventListener('error', onerror); + loader?.addEventListener('load', onload, { passive: true }); + loader?.addEventListener('error', onerror, { passive: true }); return () => { loader?.removeEventListener('load', onload); loader?.removeEventListener('error', onerror); @@ -240,7 +241,7 @@ use:swipe={() => ({})} onswipe={onSwipe} class="h-full w-full" - transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} + transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }} > {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} ; + let timer: ReturnType | null = null; const preventContextMenu = (evt: Event) => evt.preventDefault(); - let disposeables: (() => void)[] = []; + const disposeables: (() => void)[] = []; const clearLongPressTimer = () => { + if (!timer) { + return; + } clearTimeout(timer); + timer = null; for (const dispose of disposeables) { dispose(); } - disposeables = []; + disposeables.length = 0; }; let startX: number = 0; @@ -162,7 +166,7 @@ }; element.addEventListener('click', click); element.addEventListener('pointerdown', start, true); - element.addEventListener('pointerup', clearLongPressTimer, true); + element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true }); return { destroy: () => { element.removeEventListener('click', click); @@ -172,17 +176,15 @@ }; } function moveHandler(e: PointerEvent) { - var diffX = Math.abs(startX - e.clientX); - var diffY = Math.abs(startY - e.clientY); - if (diffX >= 10 || diffY >= 10) { + if (Math.abs(startX - e.clientX) >= 10 || Math.abs(startY - e.clientY) >= 10) { clearLongPressTimer(); } } onMount(() => { - document.addEventListener('scroll', clearLongPressTimer, true); - document.addEventListener('wheel', clearLongPressTimer, true); - document.addEventListener('contextmenu', clearLongPressTimer, true); - document.addEventListener('pointermove', moveHandler, true); + document.addEventListener('scroll', clearLongPressTimer, { capture: true, passive: true }); + document.addEventListener('wheel', clearLongPressTimer, { capture: true, passive: true }); + document.addEventListener('contextmenu', clearLongPressTimer, { capture: true, passive: true }); + document.addEventListener('pointermove', moveHandler, { capture: true, passive: true }); return () => { document.removeEventListener('scroll', clearLongPressTimer, true); document.removeEventListener('wheel', clearLongPressTimer, true); diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index b36082b500..77ccebe42b 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -23,12 +23,11 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { AppRoute, QueryParameter } from '$lib/constants'; + import { AppRoute, assetViewerFadeDuration, QueryParameter } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; - import type { Viewport } from '$lib/managers/timeline-manager/types'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; @@ -261,12 +260,7 @@ playerInitialized = true; }; - afterNavigate(({ from, to, type }) => { - if (type === 'enter') { - // afterNavigate triggers twice on first page load (once when mounted with 'enter' and then a second time - // with the actual 'goto' to URL). - return; - } + afterNavigate(({ from, to }) => { memoryStore.initialize().then( () => { let target = null; @@ -469,7 +463,7 @@ >
{#key current.asset.id} -
+
{#if current.asset.isVideo}