This commit is contained in:
Santo Shakil 2026-08-15 00:03:42 -04:00 committed by GitHub
commit 9e66103b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 4692 additions and 41 deletions

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,9 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
@TableIndex.sql(
'CREATE INDEX IF NOT EXISTS idx_local_asset_group ON local_asset_entity (group_date DESC, created_at DESC)',
)
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
@ -22,6 +25,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
DateTimeColumn get adjustmentTime => dateTime().nullable()();
TextColumn get groupDate => text().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();

View file

@ -28,7 +28,8 @@ SELECT
NULL as adjustmentTime,
rae.is_edited,
0 as playback_style,
rae.uploaded_at
rae.uploaded_at,
rae.group_date
FROM
remote_asset_entity rae
LEFT JOIN
@ -37,6 +38,7 @@ WHERE
rae.deleted_at IS NULL
AND rae.visibility = 0 -- timeline visibility
AND rae.owner_id IN :user_ids
AND rae.group_date IS NOT NULL
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
@ -67,7 +69,8 @@ SELECT
lae.adjustment_time,
0 as is_edited,
lae.playback_style,
NULL as uploaded_at
NULL as uploaded_at,
lae.group_date
FROM
local_asset_entity lae
WHERE NOT EXISTS (
@ -83,7 +86,8 @@ AND NOT EXISTS (
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
ORDER BY created_at DESC
AND lae.group_date IS NOT NULL
ORDER BY group_date DESC, created_at DESC
LIMIT $limit;
mergedBucket(:group_by AS INTEGER):
@ -94,14 +98,8 @@ FROM
(
SELECT
CASE
WHEN :group_by = 0 THEN COALESCE(
STRFTIME('%Y-%m-%d', rae.local_date_time),
STRFTIME('%Y-%m-%d', rae.created_at, 'localtime')
)
WHEN :group_by = 1 THEN COALESCE(
STRFTIME('%Y-%m', rae.local_date_time),
STRFTIME('%Y-%m', rae.created_at, 'localtime')
)
WHEN :group_by = 0 THEN rae.group_date
WHEN :group_by = 1 THEN SUBSTR(rae.group_date, 1, 7)
END as bucket_date
FROM
remote_asset_entity rae
@ -111,6 +109,7 @@ FROM
rae.deleted_at IS NULL
AND rae.visibility = 0 -- timeline visibility
AND rae.owner_id in :user_ids
AND rae.group_date IS NOT NULL
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
@ -118,8 +117,8 @@ FROM
UNION ALL
SELECT
CASE
WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', lae.created_at, 'localtime')
WHEN :group_by = 1 THEN STRFTIME('%Y-%m', lae.created_at, 'localtime')
WHEN :group_by = 0 THEN lae.group_date
WHEN :group_by = 1 THEN SUBSTR(lae.group_date, 1, 7)
END as bucket_date
FROM
local_asset_entity lae
@ -136,6 +135,7 @@ FROM
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
AND lae.group_date IS NOT NULL
)
GROUP BY bucket_date
ORDER BY bucket_date DESC;

View file

@ -21,6 +21,10 @@ WHERE (library_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created
ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)
''')
@TableIndex.sql('''
CREATE INDEX IF NOT EXISTS idx_remote_asset_group
ON remote_asset_entity (owner_id, visibility, deleted_at, group_date DESC, created_at DESC)
''')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)')
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const RemoteAssetEntity();
@ -35,6 +39,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
DateTimeColumn get localDateTime => dateTime().nullable()();
TextColumn get groupDate => text().nullable()();
TextColumn get thumbHash => text().nullable()();
DateTimeColumn get deletedAt => dateTime().nullable()();

View file

@ -120,7 +120,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 31;
int get schemaVersion => 32;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -318,6 +318,12 @@ class Drift extends $Drift {
from30To31: (m, v31) async {
await m.createIndex(v31.idxRemoteAssetUploaded);
},
from31To32: (m, v32) async {
await m.addColumn(v32.remoteAssetEntity, v32.remoteAssetEntity.groupDate);
await m.addColumn(v32.localAssetEntity, v32.localAssetEntity.groupDate);
await m.createIndex(v32.idxRemoteAssetGroup);
await m.createIndex(v32.idxLocalAssetGroup);
},
),
),
);

View file

@ -16506,6 +16506,696 @@ final class Schema31 extends i0.VersionedSchema {
);
}
final class Schema32 extends i0.VersionedSchema {
Schema32({required super.database}) : super(version: 32);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetCreatedAt,
idxLocalAssetGroup,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
idxRemoteAssetGroup,
idxRemoteAssetUploaded,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
assetOcrEntity,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
idxAssetOcrAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 remoteAssetEntity = Shape52(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_225,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape53 localAssetEntity = Shape53(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_225,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxLocalAssetCreatedAt = i1.Index(
'idx_local_asset_created_at',
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
);
final i1.Index idxLocalAssetGroup = i1.Index(
'idx_local_asset_group',
'CREATE INDEX IF NOT EXISTS idx_local_asset_group ON local_asset_entity (group_date DESC, created_at DESC)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
'idx_remote_asset_owner_visibility_deleted_created',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
);
final i1.Index idxRemoteAssetGroup = i1.Index(
'idx_remote_asset_group',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_group ON remote_asset_entity (owner_id, visibility, deleted_at, group_date DESC, created_at DESC)',
);
final i1.Index idxRemoteAssetUploaded = i1.Index(
'idx_remote_asset_uploaded',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 settings = Shape49(
source: i0.VersionedTable(
entityName: 'settings',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_224, _column_115],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 assetOcrEntity = Shape51(
source: i0.VersionedTable(
entityName: 'asset_ocr_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_213,
_column_214,
_column_215,
_column_216,
_column_217,
_column_218,
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_201,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteExifCity = i1.Index(
'idx_remote_exif_city',
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
'idx_asset_face_visible_person',
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
final i1.Index idxAssetOcrAssetId = i1.Index(
'idx_asset_ocr_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
);
}
class Shape52 extends i0.VersionedTable {
Shape52({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get localDateTime =>
columnsByName['local_date_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get groupDate =>
columnsByName['group_date']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get thumbHash =>
columnsByName['thumb_hash']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uploadedAt =>
columnsByName['uploaded_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get livePhotoVideoId =>
columnsByName['live_photo_video_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stackId =>
columnsByName['stack_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get libraryId =>
columnsByName['library_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isEdited =>
columnsByName['is_edited']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
i1.GeneratedColumn<String>(
'group_date',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
class Shape53 extends i0.VersionedTable {
Shape53({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get groupDate =>
columnsByName['group_date']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<int> get playbackStyle =>
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -16537,6 +17227,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -16690,6 +17381,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from30To31(migrator, schema);
return 31;
case 31:
final schema = Schema32(database: database);
final migrator = i1.Migrator(database, schema);
await from31To32(migrator, schema);
return 32;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -16727,6 +17423,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
required Future<void> Function(i1.Migrator m, Schema32 schema) from31To32,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -16759,5 +17456,6 @@ i1.OnUpgrade stepByStep({
from28To29: from28To29,
from29To30: from29To30,
from30To31: from30To31,
from31To32: from31To32,
),
);

View file

@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
@ -307,6 +308,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
latitude: Value(asset.latitude),
longitude: Value(asset.longitude),
adjustmentTime: Value(asset.adjustmentTime),
groupDate: Value(timelineGroupDate(asset.createdAt.toLocal())),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity,
@ -337,6 +339,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
orientation: Value(asset.orientation),
isFavorite: Value(asset.isFavorite),
playbackStyle: Value(asset.playbackStyle),
groupDate: Value(timelineGroupDate(asset.createdAt.toLocal())),
);
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity,

View file

@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
enum SortRemoteAlbumsBy { id, updatedAt }
@ -327,6 +328,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
isFavorite: Value(source.isFavorite),
visibility: const Value(AssetVisibility.timeline),
isEdited: Value(source.isEdited),
groupDate: Value(timelineGroupDate(source.createdAt.toLocal())),
),
mode: InsertMode.insertOrIgnore,
);

View file

@ -10,7 +10,9 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
@ -280,6 +282,8 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime, {String? timeZone}) {
final (adjusted, _) = applyTimezoneOffset(dateTime: dateTime, timeZone: timeZone);
final groupDate = timelineGroupDate(adjusted);
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
@ -292,7 +296,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
);
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
RemoteAssetEntityCompanion(createdAt: Value(dateTime), groupDate: Value(groupDate)),
where: (e) => e.id.equals(id),
);
}

View file

@ -30,6 +30,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey;
import 'package:openapi/api.dart' hide AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey;
@ -199,6 +200,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
try {
await _db.batch((batch) {
for (final asset in data) {
final groupDate = asset.localDateTime ?? asset.fileCreatedAt?.toLocal();
final companion = RemoteAssetEntityCompanion(
name: Value(asset.originalFileName),
type: Value(asset.type.toAssetType()),
@ -210,6 +212,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
isFavorite: Value(asset.isFavorite),
ownerId: Value(asset.ownerId),
localDateTime: Value(asset.localDateTime),
groupDate: groupDate == null ? const Value.absent() : Value(timelineGroupDate(groupDate)),
thumbHash: Value(asset.thumbhash),
deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()),
@ -238,6 +241,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
try {
await _db.batch((batch) {
for (final asset in data) {
final groupDate = asset.localDateTime ?? asset.fileCreatedAt?.toLocal();
final companion = RemoteAssetEntityCompanion(
name: Value(asset.originalFileName),
type: Value(asset.type.toAssetType()),
@ -249,6 +253,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
isFavorite: Value(asset.isFavorite),
ownerId: Value(asset.ownerId),
localDateTime: Value(asset.localDateTime),
groupDate: groupDate == null ? const Value.absent() : Value(timelineGroupDate(groupDate)),
thumbHash: Value(asset.thumbhash),
deletedAt: Value(asset.deletedAt),
visibility: Value(asset.visibility.toAssetVisibility()),

View file

@ -57,12 +57,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
);
Stream<List<Bucket>> _watchMainBucket(List<String> userIds, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
throw UnsupportedError("GroupAssetsBy.none is not supported for watchMainBucket");
if (groupBy == GroupAssetsBy.none || groupBy == GroupAssetsBy.auto) {
throw UnsupportedError("$groupBy is not supported for watchMainBucket");
}
return _db.mergedAssetDrift.mergedBucket(userIds: userIds, groupBy: groupBy.index).map((row) {
final date = row.bucketDate.truncateDate(groupBy);
final date = row.bucketDate!.truncateDate(groupBy);
return TimeBucket(date: date, assetCount: row.assetCount);
}).watch();
}

View file

@ -8,6 +8,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.d
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
typedef TrashedAsset = ({String albumId, LocalAsset asset});
@ -203,6 +204,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
isFavorite: Value(e.isFavorite),
orientation: Value(e.orientation),
playbackStyle: Value(e.playbackStyle),
groupDate: Value(timelineGroupDate(e.createdAt.toLocal())),
);
});

View file

@ -1,3 +1,5 @@
import 'package:intl/intl.dart';
const int _maxMillisecondsSinceEpoch = 8640000000000000; // 275760-09-13
const int _minMillisecondsSinceEpoch = -62135596800000; // 0001-01-01
@ -17,3 +19,5 @@ DateTime? tryFromSecondsSinceEpoch(int? secondsSinceEpoch, {bool isUtc = false})
return null;
}
}
String timelineGroupDate(DateTime value) => DateFormat('yyyy-MM-dd', 'en_US').format(value);

View file

@ -20,7 +20,7 @@ import 'package:immich_mobile/infrastructure/repositories/settings.repository.da
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
const int targetVersion = 26;
const int targetVersion = 28;
Future<void> migrateDatabaseIfNeeded(Drift drift) async {
final int? storedVersion = Store.tryGet(StoreKey.version);
@ -34,6 +34,10 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
await _migrateTo26(drift);
}
if (version < 28) {
await _migrateTo28(drift);
}
if (storedVersion == null) {
await FeatureMessageService(SettingsRepository.instance).markSeen();
}
@ -42,6 +46,17 @@ Future<void> migrateDatabaseIfNeeded(Drift drift) async {
return;
}
Future<void> _migrateTo28(Drift drift) => backfillAssetGroupDates(drift);
Future<void> backfillAssetGroupDates(Drift drift) async {
await drift.customStatement(
"UPDATE remote_asset_entity SET group_date = COALESCE(STRFTIME('%Y-%m-%d', local_date_time), STRFTIME('%Y-%m-%d', created_at, 'localtime'))",
);
await drift.customStatement(
"UPDATE local_asset_entity SET group_date = STRFTIME('%Y-%m-%d', created_at, 'localtime')",
);
}
Future<void> _migrateTo25() async {
final accessToken = Store.tryGet(StoreKey.accessToken);
if (accessToken == null || accessToken.isEmpty) {

View file

@ -3,7 +3,11 @@
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'generated/schema.dart';
import 'generated/schema_v1.dart' as v1;
@ -35,4 +39,73 @@ void main() {
});
}
});
group('v32 group_date backfill', () {
test(
'garbage created_at survives migration and is filtered from the timeline',
() async {
await initializeDateFormatting();
final schema = await verifier.schemaAt(31);
schema.rawDatabase.execute(
"INSERT INTO local_album_entity (id, name, backup_selection) VALUES ('album-1', 'Camera', 0)",
);
schema.rawDatabase.execute(
"INSERT INTO local_asset_entity (id, name, type, created_at, updated_at) VALUES ('garbage', 'g.jpg', 1, '57780-01-01T00:00:00.000Z', '57780-01-01T00:00:00.000Z')",
);
schema.rawDatabase.execute(
"INSERT INTO local_asset_entity (id, name, type, created_at, updated_at) VALUES ('good', 'ok.jpg', 1, '2026-07-24T10:00:00.000Z', '2026-07-24T10:00:00.000Z')",
);
schema.rawDatabase.execute(
"INSERT INTO local_album_asset_entity (asset_id, album_id) VALUES ('garbage', 'album-1')",
);
schema.rawDatabase.execute(
"INSERT INTO local_album_asset_entity (asset_id, album_id) VALUES ('good', 'album-1')",
);
final db = Drift(schema.newConnection());
await verifier.migrateAndValidate(db, 32);
await backfillAssetGroupDates(db);
final repo = DriftTimelineRepository(db);
final buckets = await repo
.main(const ['user-1'], GroupAssetsBy.day)
.bucketSource()
.first;
expect(buckets, hasLength(1));
expect(buckets.single.assetCount, 1);
expect((buckets.single as TimeBucket).date, DateTime(2026, 7, 24));
await db.close();
},
);
test('a created_at heal before the backfill lands in the header day', () async {
await initializeDateFormatting();
final schema = await verifier.schemaAt(31);
schema.rawDatabase.execute(
"INSERT INTO local_album_entity (id, name, backup_selection) VALUES ('album-1', 'Camera', 0)",
);
schema.rawDatabase.execute(
"INSERT INTO local_asset_entity (id, name, type, created_at, updated_at) VALUES ('healed', 'h.jpg', 1, '2027-01-01T00:00:00.000Z', '2026-07-20T10:00:00.000Z')",
);
schema.rawDatabase.execute(
"INSERT INTO local_album_asset_entity (asset_id, album_id) VALUES ('healed', 'album-1')",
);
final db = Drift(schema.newConnection());
await verifier.migrateAndValidate(db, 32);
await db.customStatement(
"UPDATE local_asset_entity SET created_at = updated_at WHERE julianday(created_at) > julianday(updated_at)",
);
await backfillAssetGroupDates(db);
final repo = DriftTimelineRepository(db);
final buckets = await repo
.main(const ['user-1'], GroupAssetsBy.day)
.bucketSource()
.first;
expect((buckets.single as TimeBucket).date, DateTime(2026, 7, 20));
await db.close();
});
});
}

View file

@ -1,52 +1,184 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.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_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/migration.dart';
const _userId = 'user-1';
const _albumId = 'album-1';
void main() {
late Drift db;
setUp(() {
setUp(() async {
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await db
.into(db.userEntity)
.insert(UserEntityCompanion.insert(id: _userId, email: 'user-1@test.dev', name: 'User 1'));
await db
.into(db.localAlbumEntity)
.insert(
LocalAlbumEntityCompanion.insert(id: _albumId, name: 'Camera', backupSelection: BackupSelection.selected),
);
});
tearDown(() async {
await db.close();
});
test('mergedBucket falls back to createdAt when localDateTime is null', () async {
const userId = 'user-1';
final createdAt = DateTime(2024, 1, 1, 12);
Future<void> insertRemote(String id, {required DateTime createdAt, DateTime? localDateTime}) => db
.into(db.remoteAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: id,
name: '$id.jpg',
type: AssetType.image,
checksum: 'checksum-$id',
ownerId: _userId,
visibility: AssetVisibility.timeline,
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
uploadedAt: Value(createdAt),
localDateTime: Value(localDateTime),
groupDate: Value(timelineGroupDate(localDateTime ?? createdAt.toLocal())),
),
);
Future<void> insertLocal(String id, {required DateTime createdAt}) async {
await db
.into(db.userEntity)
.insert(UserEntityCompanion.insert(id: userId, email: 'user-1@test.dev', name: 'User 1'));
await db
.into(db.remoteAssetEntity)
.into(db.localAssetEntity)
.insert(
RemoteAssetEntityCompanion.insert(
id: 'asset-1',
name: 'asset-1.jpg',
LocalAssetEntityCompanion.insert(
id: id,
name: '$id.jpg',
type: AssetType.image,
checksum: 'checksum-1',
ownerId: userId,
visibility: AssetVisibility.timeline,
checksum: Value('checksum-$id'),
createdAt: Value(createdAt),
updatedAt: Value(createdAt),
uploadedAt: Value(createdAt),
localDateTime: const Value(null),
groupDate: Value(timelineGroupDate(createdAt.toLocal())),
),
);
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(assetId: id, albumId: _albumId));
}
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [userId]).get();
// the timeline pairs headers to assets by running offset into one flat list
Future<List<(String, String)>> headerForEachAsset(GroupAssetsBy groupBy) async {
final buckets = await db.mergedAssetDrift.mergedBucket(groupBy: groupBy.index, userIds: [_userId]).get();
final assets = await db.mergedAssetDrift.mergedAsset(userIds: [_userId], limit: (_) => Limit(1000, 0)).get();
expect(buckets, hasLength(1));
expect(buckets.single.assetCount, 1);
expect(buckets.single.bucketDate, isNotEmpty);
final pairs = <(String, String)>[];
var offset = 0;
for (final bucket in buckets) {
for (final asset in assets.skip(offset).take(bucket.assetCount)) {
pairs.add(((asset.remoteId ?? asset.localId)!, bucket.bucketDate!));
}
offset += bucket.assetCount;
}
return pairs;
}
// #29864: buckets grouped by localDateTime while assets sorted by createdAt
group('mergedAsset ordering matches mergedBucket grouping', () {
Future<void> seedGhost() async {
await insertRemote('asset-a', createdAt: DateTime(2026, 4, 26, 10));
await insertRemote('asset-b', createdAt: DateTime(2026, 4, 26, 12));
await insertRemote('ghost', createdAt: DateTime(2026, 4, 26, 11), localDateTime: DateTime.utc(2027, 3, 4, 12));
}
test('asset under each header belongs to that header date', () async {
await seedGhost();
expect(await headerForEachAsset(GroupAssetsBy.day), [
('ghost', '2027-03-04'),
('asset-b', '2026-04-26'),
('asset-a', '2026-04-26'),
]);
});
test('deleting the asset under a header removes the empty header', () async {
await seedGhost();
await db.remoteAssetEntity.deleteWhere((row) => row.id.equals('ghost'));
final buckets = await db.mergedAssetDrift
.mergedBucket(groupBy: GroupAssetsBy.day.index, userIds: [_userId])
.get();
expect(buckets.map((b) => b.bucketDate), ['2026-04-26']);
expect(buckets.single.assetCount, 2);
expect(await headerForEachAsset(GroupAssetsBy.day), [('asset-b', '2026-04-26'), ('asset-a', '2026-04-26')]);
});
test('same-day assets order by createdAt like the web timeline', () async {
await insertRemote(
'shot-late-upload-early',
createdAt: DateTime.utc(2026, 7, 24, 1),
localDateTime: DateTime.utc(2026, 7, 24, 22),
);
await insertRemote(
'shot-early-upload-late',
createdAt: DateTime.utc(2026, 7, 24, 15),
localDateTime: DateTime.utc(2026, 7, 24, 8),
);
expect(await headerForEachAsset(GroupAssetsBy.day), [
('shot-early-upload-late', '2026-07-24'),
('shot-late-upload-early', '2026-07-24'),
]);
});
});
test('device only assets are interleaved with remote assets by the date shown', () async {
await insertRemote('remote', createdAt: DateTime.utc(2024, 4, 26, 3), localDateTime: DateTime.utc(2024, 4, 26, 10));
await insertLocal('device', createdAt: DateTime(2024, 4, 26, 14));
await insertRemote('older', createdAt: DateTime.utc(2024, 4, 25, 3), localDateTime: DateTime.utc(2024, 4, 25, 10));
expect(await headerForEachAsset(GroupAssetsBy.day), [
('device', '2024-04-26'),
('remote', '2024-04-26'),
('older', '2024-04-25'),
]);
});
test('month grouping keeps every asset under its own month header', () async {
await insertRemote('may', createdAt: DateTime.utc(2024, 4, 30, 23), localDateTime: DateTime.utc(2024, 5, 1, 8));
await insertRemote('april', createdAt: DateTime.utc(2024, 5, 1, 1), localDateTime: DateTime.utc(2024, 4, 30, 20));
expect(await headerForEachAsset(GroupAssetsBy.month), [('may', '2024-05'), ('april', '2024-04')]);
});
test('a created_at heal before the backfill lands in the header day', () async {
await db
.into(db.localAssetEntity)
.insert(
LocalAssetEntityCompanion.insert(
id: 'healed',
name: 'healed.jpg',
type: AssetType.image,
createdAt: Value(DateTime.utc(2027)),
updatedAt: Value(DateTime.utc(2026, 7, 20)),
),
);
await db
.into(db.localAlbumAssetEntity)
.insert(LocalAlbumAssetEntityCompanion.insert(assetId: 'healed', albumId: _albumId));
await db.customStatement(
"UPDATE local_asset_entity SET created_at = updated_at WHERE julianday(created_at) > julianday(updated_at)",
);
await backfillAssetGroupDates(db);
expect(await headerForEachAsset(GroupAssetsBy.day), [('healed', '2026-07-20')]);
});
}

View file

@ -23,6 +23,7 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:uuid/uuid.dart';
@ -145,6 +146,7 @@ class MediumRepositoryContext {
livePhotoVideoId: .new(livePhotoVideoId),
stackId: .new(stackId),
localDateTime: .new(createdAt.toLocal()),
groupDate: .new(timelineGroupDate(createdAt.toLocal())),
thumbHash: .new(TestUtils.uuid(thumbHash)),
libraryId: .new(TestUtils.uuid(libraryId)),
),
@ -282,6 +284,7 @@ class MediumRepositoryContext {
DateTime? updatedAt,
}) async {
id ??= TestUtils.uuid();
createdAt ??= TestUtils.date();
return db
.into(db.localAssetEntity)
.insertReturning(
@ -294,7 +297,8 @@ class MediumRepositoryContext {
orientation: .new(orientation ?? 0),
updatedAt: .new(TestUtils.date(updatedAt)),
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
createdAt: .new(TestUtils.date(createdAt)),
createdAt: .new(createdAt),
groupDate: .new(timelineGroupDate(createdAt.toLocal())),
type: .new(type ?? .image),
isFavorite: .new(isFavorite ?? false),
iCloudId: .new(TestUtils.uuid(iCloudId)),