mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
+1
File diff suppressed because one or more lines are too long
@@ -18,3 +18,5 @@ enum ActionSource { timeline, viewer }
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
enum AssetDateAggregation { start, end }
|
||||
|
||||
@@ -43,8 +43,8 @@ class RemoteAlbumService {
|
||||
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||
AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
|
||||
AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
|
||||
};
|
||||
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
|
||||
|
||||
@@ -172,46 +172,25 @@ class RemoteAlbumService {
|
||||
return _repository.getAlbumsContainingAsset(assetId);
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their newest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
|
||||
for (final album in albums) {
|
||||
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
|
||||
Future<List<RemoteAlbum>> _sortByAssetDate(
|
||||
List<RemoteAlbum> albums, {
|
||||
required AssetDateAggregation aggregation,
|
||||
}) async {
|
||||
if (albums.isEmpty) return [];
|
||||
|
||||
final albumIds = albums.map((e) => e.id).toList();
|
||||
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
|
||||
|
||||
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
|
||||
|
||||
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().toList();
|
||||
|
||||
if (sortedAlbums.length < albums.length) {
|
||||
final returnedIdSet = sortedIds.toSet();
|
||||
final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id));
|
||||
sortedAlbums.addAll(emptyAlbums);
|
||||
}
|
||||
|
||||
// await all database queries
|
||||
final entries = await Future.wait(
|
||||
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||
);
|
||||
final assetTimestamps = Map.fromEntries(entries);
|
||||
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
|
||||
// map album IDs to their oldest asset dates
|
||||
final Map<String, Future<DateTime?>> assetTimestampFutures = {
|
||||
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
|
||||
};
|
||||
|
||||
// await all database queries
|
||||
final entries = await Future.wait(
|
||||
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||
);
|
||||
final assetTimestamps = Map.fromEntries(entries);
|
||||
|
||||
final sorted = albums.sorted((a, b) {
|
||||
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return aDate.compareTo(bDate);
|
||||
});
|
||||
|
||||
return sorted;
|
||||
return sortedAlbums;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,12 @@ class SyncStreamService {
|
||||
return false;
|
||||
}
|
||||
|
||||
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
||||
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
||||
|
||||
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
|
||||
final migrations = (jsonDecode(value) as List).cast<String>();
|
||||
int previousLength = migrations.length;
|
||||
await _runPreSyncTasks(migrations, semVer);
|
||||
await _runPreSyncTasks(migrations, serverSemVer);
|
||||
|
||||
if (migrations.length != previousLength) {
|
||||
_logger.info("Updated pre-sync migration status: $migrations");
|
||||
@@ -82,10 +82,14 @@ class SyncStreamService {
|
||||
|
||||
// Start the sync stream and handle events
|
||||
bool shouldReset = false;
|
||||
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
|
||||
await _syncApiRepository.streamChanges(
|
||||
_handleEvents,
|
||||
serverVersion: serverSemVer,
|
||||
onReset: () => shouldReset = true,
|
||||
);
|
||||
if (shouldReset) {
|
||||
_logger.info("Resetting sync state as requested by server");
|
||||
await _syncApiRepository.streamChanges(_handleEvents);
|
||||
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
|
||||
}
|
||||
|
||||
previousLength = migrations.length;
|
||||
@@ -282,6 +286,8 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.deletePeopleV1(data.cast());
|
||||
case SyncEntityType.assetFaceV1:
|
||||
return _syncStreamRepository.updateAssetFacesV1(data.cast());
|
||||
case SyncEntityType.assetFaceV2:
|
||||
return _syncStreamRepository.updateAssetFacesV2(data.cast());
|
||||
case SyncEntityType.assetFaceDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
||||
default:
|
||||
|
||||
@@ -28,6 +28,10 @@ class AssetFaceEntity extends Table with DriftDefaultsMixin {
|
||||
|
||||
TextColumn get sourceType => text()();
|
||||
|
||||
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
|
||||
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
+202
-68
@@ -5,11 +5,12 @@ import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.da
|
||||
as i1;
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'
|
||||
as i2;
|
||||
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
||||
as i3;
|
||||
import 'package:drift/internal/modular.dart' as i4;
|
||||
as i4;
|
||||
import 'package:drift/internal/modular.dart' as i5;
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
|
||||
as i5;
|
||||
as i6;
|
||||
|
||||
typedef $$AssetFaceEntityTableCreateCompanionBuilder =
|
||||
i1.AssetFaceEntityCompanion Function({
|
||||
@@ -23,6 +24,8 @@ typedef $$AssetFaceEntityTableCreateCompanionBuilder =
|
||||
required int boundingBoxX2,
|
||||
required int boundingBoxY2,
|
||||
required String sourceType,
|
||||
i0.Value<bool> isVisible,
|
||||
i0.Value<DateTime?> deletedAt,
|
||||
});
|
||||
typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
|
||||
i1.AssetFaceEntityCompanion Function({
|
||||
@@ -36,6 +39,8 @@ typedef $$AssetFaceEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<int> boundingBoxX2,
|
||||
i0.Value<int> boundingBoxY2,
|
||||
i0.Value<String> sourceType,
|
||||
i0.Value<bool> isVisible,
|
||||
i0.Value<DateTime?> deletedAt,
|
||||
});
|
||||
|
||||
final class $$AssetFaceEntityTableReferences
|
||||
@@ -51,29 +56,29 @@ final class $$AssetFaceEntityTableReferences
|
||||
super.$_typedResult,
|
||||
);
|
||||
|
||||
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
||||
i4.ReadDatabaseContainer(db)
|
||||
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
|
||||
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
|
||||
.createAlias(
|
||||
i0.$_aliasNameGenerator(
|
||||
i4.ReadDatabaseContainer(db)
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
|
||||
.assetId,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
||||
),
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
||||
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
||||
final $_column = $_itemColumn<String>('asset_id')!;
|
||||
|
||||
final manager = i3
|
||||
final manager = i4
|
||||
.$$RemoteAssetEntityTableTableManager(
|
||||
$_db,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
$_db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
)
|
||||
.filter((f) => f.id.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
|
||||
@@ -83,29 +88,29 @@ final class $$AssetFaceEntityTableReferences
|
||||
);
|
||||
}
|
||||
|
||||
static i5.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
|
||||
i4.ReadDatabaseContainer(db)
|
||||
.resultSet<i5.$PersonEntityTable>('person_entity')
|
||||
static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i6.$PersonEntityTable>('person_entity')
|
||||
.createAlias(
|
||||
i0.$_aliasNameGenerator(
|
||||
i4.ReadDatabaseContainer(db)
|
||||
i5.ReadDatabaseContainer(db)
|
||||
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
|
||||
.personId,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity').id,
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity').id,
|
||||
),
|
||||
);
|
||||
|
||||
i5.$$PersonEntityTableProcessedTableManager? get personId {
|
||||
i6.$$PersonEntityTableProcessedTableManager? get personId {
|
||||
final $_column = $_itemColumn<String>('person_id');
|
||||
if ($_column == null) return null;
|
||||
final manager = i5
|
||||
final manager = i6
|
||||
.$$PersonEntityTableTableManager(
|
||||
$_db,
|
||||
i4.ReadDatabaseContainer(
|
||||
i5.ReadDatabaseContainer(
|
||||
$_db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
)
|
||||
.filter((f) => f.id.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_personIdTable($_db));
|
||||
@@ -165,24 +170,34 @@ class $$AssetFaceEntityTableFilterComposer
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableFilterComposer get assetId {
|
||||
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||
i0.ColumnFilters<bool> get isVisible => $composableBuilder(
|
||||
column: $table.isVisible,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<DateTime> get deletedAt => $composableBuilder(
|
||||
column: $table.deletedAt,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i4.$$RemoteAssetEntityTableFilterComposer get assetId {
|
||||
final i4.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.assetId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i3.$$RemoteAssetEntityTableFilterComposer(
|
||||
}) => i4.$$RemoteAssetEntityTableFilterComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -192,24 +207,24 @@ class $$AssetFaceEntityTableFilterComposer
|
||||
return composer;
|
||||
}
|
||||
|
||||
i5.$$PersonEntityTableFilterComposer get personId {
|
||||
final i5.$$PersonEntityTableFilterComposer composer = $composerBuilder(
|
||||
i6.$$PersonEntityTableFilterComposer get personId {
|
||||
final i6.$$PersonEntityTableFilterComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.personId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i5.$$PersonEntityTableFilterComposer(
|
||||
}) => i6.$$PersonEntityTableFilterComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -269,25 +284,35 @@ class $$AssetFaceEntityTableOrderingComposer
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
||||
final i3.$$RemoteAssetEntityTableOrderingComposer composer =
|
||||
i0.ColumnOrderings<bool> get isVisible => $composableBuilder(
|
||||
column: $table.isVisible,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<DateTime> get deletedAt => $composableBuilder(
|
||||
column: $table.deletedAt,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i4.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
||||
final i4.$$RemoteAssetEntityTableOrderingComposer composer =
|
||||
$composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.assetId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i3.$$RemoteAssetEntityTableOrderingComposer(
|
||||
}) => i4.$$RemoteAssetEntityTableOrderingComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -297,24 +322,24 @@ class $$AssetFaceEntityTableOrderingComposer
|
||||
return composer;
|
||||
}
|
||||
|
||||
i5.$$PersonEntityTableOrderingComposer get personId {
|
||||
final i5.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
|
||||
i6.$$PersonEntityTableOrderingComposer get personId {
|
||||
final i6.$$PersonEntityTableOrderingComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.personId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i5.$$PersonEntityTableOrderingComposer(
|
||||
}) => i6.$$PersonEntityTableOrderingComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -372,25 +397,31 @@ class $$AssetFaceEntityTableAnnotationComposer
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
||||
final i3.$$RemoteAssetEntityTableAnnotationComposer composer =
|
||||
i0.GeneratedColumn<bool> get isVisible =>
|
||||
$composableBuilder(column: $table.isVisible, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumn<DateTime> get deletedAt =>
|
||||
$composableBuilder(column: $table.deletedAt, builder: (column) => column);
|
||||
|
||||
i4.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
||||
final i4.$$RemoteAssetEntityTableAnnotationComposer composer =
|
||||
$composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.assetId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i3.$$RemoteAssetEntityTableAnnotationComposer(
|
||||
}) => i4.$$RemoteAssetEntityTableAnnotationComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -400,24 +431,24 @@ class $$AssetFaceEntityTableAnnotationComposer
|
||||
return composer;
|
||||
}
|
||||
|
||||
i5.$$PersonEntityTableAnnotationComposer get personId {
|
||||
final i5.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
|
||||
i6.$$PersonEntityTableAnnotationComposer get personId {
|
||||
final i6.$$PersonEntityTableAnnotationComposer composer = $composerBuilder(
|
||||
composer: this,
|
||||
getCurrentColumn: (t) => t.personId,
|
||||
referencedTable: i4.ReadDatabaseContainer(
|
||||
referencedTable: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
getReferencedColumn: (t) => t.id,
|
||||
builder:
|
||||
(
|
||||
joinBuilder, {
|
||||
$addJoinBuilderToRootComposer,
|
||||
$removeJoinBuilderFromRootComposer,
|
||||
}) => i5.$$PersonEntityTableAnnotationComposer(
|
||||
}) => i6.$$PersonEntityTableAnnotationComposer(
|
||||
$db: $db,
|
||||
$table: i4.ReadDatabaseContainer(
|
||||
$table: i5.ReadDatabaseContainer(
|
||||
$db,
|
||||
).resultSet<i5.$PersonEntityTable>('person_entity'),
|
||||
).resultSet<i6.$PersonEntityTable>('person_entity'),
|
||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
||||
joinBuilder: joinBuilder,
|
||||
$removeJoinBuilderFromRootComposer:
|
||||
@@ -468,6 +499,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
i0.Value<int> boundingBoxX2 = const i0.Value.absent(),
|
||||
i0.Value<int> boundingBoxY2 = const i0.Value.absent(),
|
||||
i0.Value<String> sourceType = const i0.Value.absent(),
|
||||
i0.Value<bool> isVisible = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
|
||||
}) => i1.AssetFaceEntityCompanion(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
@@ -479,6 +512,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
boundingBoxX2: boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2,
|
||||
sourceType: sourceType,
|
||||
isVisible: isVisible,
|
||||
deletedAt: deletedAt,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -492,6 +527,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
required int boundingBoxX2,
|
||||
required int boundingBoxY2,
|
||||
required String sourceType,
|
||||
i0.Value<bool> isVisible = const i0.Value.absent(),
|
||||
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
|
||||
}) => i1.AssetFaceEntityCompanion.insert(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
@@ -503,6 +540,8 @@ class $$AssetFaceEntityTableTableManager
|
||||
boundingBoxX2: boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2,
|
||||
sourceType: sourceType,
|
||||
isVisible: isVisible,
|
||||
deletedAt: deletedAt,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
@@ -709,6 +748,33 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const i0.VerificationMeta _isVisibleMeta = const i0.VerificationMeta(
|
||||
'isVisible',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<bool> isVisible = i0.GeneratedColumn<bool>(
|
||||
'is_visible',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_visible" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const i3.Constant(true),
|
||||
);
|
||||
static const i0.VerificationMeta _deletedAtMeta = const i0.VerificationMeta(
|
||||
'deletedAt',
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumn<DateTime> deletedAt =
|
||||
i0.GeneratedColumn<DateTime>(
|
||||
'deleted_at',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.dateTime,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
id,
|
||||
@@ -721,6 +787,8 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
boundingBoxX2,
|
||||
boundingBoxY2,
|
||||
sourceType,
|
||||
isVisible,
|
||||
deletedAt,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -824,6 +892,18 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
} else if (isInserting) {
|
||||
context.missing(_sourceTypeMeta);
|
||||
}
|
||||
if (data.containsKey('is_visible')) {
|
||||
context.handle(
|
||||
_isVisibleMeta,
|
||||
isVisible.isAcceptableOrUnknown(data['is_visible']!, _isVisibleMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('deleted_at')) {
|
||||
context.handle(
|
||||
_deletedAtMeta,
|
||||
deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -873,6 +953,14 @@ class $AssetFaceEntityTable extends i2.AssetFaceEntity
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}source_type'],
|
||||
)!,
|
||||
isVisible: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_visible'],
|
||||
)!,
|
||||
deletedAt: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}deleted_at'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -899,6 +987,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
final int boundingBoxX2;
|
||||
final int boundingBoxY2;
|
||||
final String sourceType;
|
||||
final bool isVisible;
|
||||
final DateTime? deletedAt;
|
||||
const AssetFaceEntityData({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
@@ -910,6 +1000,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY2,
|
||||
required this.sourceType,
|
||||
required this.isVisible,
|
||||
this.deletedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -926,6 +1018,10 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
map['bounding_box_x2'] = i0.Variable<int>(boundingBoxX2);
|
||||
map['bounding_box_y2'] = i0.Variable<int>(boundingBoxY2);
|
||||
map['source_type'] = i0.Variable<String>(sourceType);
|
||||
map['is_visible'] = i0.Variable<bool>(isVisible);
|
||||
if (!nullToAbsent || deletedAt != null) {
|
||||
map['deleted_at'] = i0.Variable<DateTime>(deletedAt);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -945,6 +1041,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
boundingBoxX2: serializer.fromJson<int>(json['boundingBoxX2']),
|
||||
boundingBoxY2: serializer.fromJson<int>(json['boundingBoxY2']),
|
||||
sourceType: serializer.fromJson<String>(json['sourceType']),
|
||||
isVisible: serializer.fromJson<bool>(json['isVisible']),
|
||||
deletedAt: serializer.fromJson<DateTime?>(json['deletedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -961,6 +1059,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
'boundingBoxX2': serializer.toJson<int>(boundingBoxX2),
|
||||
'boundingBoxY2': serializer.toJson<int>(boundingBoxY2),
|
||||
'sourceType': serializer.toJson<String>(sourceType),
|
||||
'isVisible': serializer.toJson<bool>(isVisible),
|
||||
'deletedAt': serializer.toJson<DateTime?>(deletedAt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -975,6 +1075,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
int? boundingBoxX2,
|
||||
int? boundingBoxY2,
|
||||
String? sourceType,
|
||||
bool? isVisible,
|
||||
i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
|
||||
}) => i1.AssetFaceEntityData(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
@@ -986,6 +1088,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt,
|
||||
);
|
||||
AssetFaceEntityData copyWithCompanion(i1.AssetFaceEntityCompanion data) {
|
||||
return AssetFaceEntityData(
|
||||
@@ -1013,6 +1117,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
sourceType: data.sourceType.present
|
||||
? data.sourceType.value
|
||||
: this.sourceType,
|
||||
isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible,
|
||||
deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1028,7 +1134,9 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
..write('boundingBoxY1: $boundingBoxY1, ')
|
||||
..write('boundingBoxX2: $boundingBoxX2, ')
|
||||
..write('boundingBoxY2: $boundingBoxY2, ')
|
||||
..write('sourceType: $sourceType')
|
||||
..write('sourceType: $sourceType, ')
|
||||
..write('isVisible: $isVisible, ')
|
||||
..write('deletedAt: $deletedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1045,6 +1153,8 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
boundingBoxX2,
|
||||
boundingBoxY2,
|
||||
sourceType,
|
||||
isVisible,
|
||||
deletedAt,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1059,7 +1169,9 @@ class AssetFaceEntityData extends i0.DataClass
|
||||
other.boundingBoxY1 == this.boundingBoxY1 &&
|
||||
other.boundingBoxX2 == this.boundingBoxX2 &&
|
||||
other.boundingBoxY2 == this.boundingBoxY2 &&
|
||||
other.sourceType == this.sourceType);
|
||||
other.sourceType == this.sourceType &&
|
||||
other.isVisible == this.isVisible &&
|
||||
other.deletedAt == this.deletedAt);
|
||||
}
|
||||
|
||||
class AssetFaceEntityCompanion
|
||||
@@ -1074,6 +1186,8 @@ class AssetFaceEntityCompanion
|
||||
final i0.Value<int> boundingBoxX2;
|
||||
final i0.Value<int> boundingBoxY2;
|
||||
final i0.Value<String> sourceType;
|
||||
final i0.Value<bool> isVisible;
|
||||
final i0.Value<DateTime?> deletedAt;
|
||||
const AssetFaceEntityCompanion({
|
||||
this.id = const i0.Value.absent(),
|
||||
this.assetId = const i0.Value.absent(),
|
||||
@@ -1085,6 +1199,8 @@ class AssetFaceEntityCompanion
|
||||
this.boundingBoxX2 = const i0.Value.absent(),
|
||||
this.boundingBoxY2 = const i0.Value.absent(),
|
||||
this.sourceType = const i0.Value.absent(),
|
||||
this.isVisible = const i0.Value.absent(),
|
||||
this.deletedAt = const i0.Value.absent(),
|
||||
});
|
||||
AssetFaceEntityCompanion.insert({
|
||||
required String id,
|
||||
@@ -1097,6 +1213,8 @@ class AssetFaceEntityCompanion
|
||||
required int boundingBoxX2,
|
||||
required int boundingBoxY2,
|
||||
required String sourceType,
|
||||
this.isVisible = const i0.Value.absent(),
|
||||
this.deletedAt = const i0.Value.absent(),
|
||||
}) : id = i0.Value(id),
|
||||
assetId = i0.Value(assetId),
|
||||
imageWidth = i0.Value(imageWidth),
|
||||
@@ -1117,6 +1235,8 @@ class AssetFaceEntityCompanion
|
||||
i0.Expression<int>? boundingBoxX2,
|
||||
i0.Expression<int>? boundingBoxY2,
|
||||
i0.Expression<String>? sourceType,
|
||||
i0.Expression<bool>? isVisible,
|
||||
i0.Expression<DateTime>? deletedAt,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (id != null) 'id': id,
|
||||
@@ -1129,6 +1249,8 @@ class AssetFaceEntityCompanion
|
||||
if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2,
|
||||
if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2,
|
||||
if (sourceType != null) 'source_type': sourceType,
|
||||
if (isVisible != null) 'is_visible': isVisible,
|
||||
if (deletedAt != null) 'deleted_at': deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1143,6 +1265,8 @@ class AssetFaceEntityCompanion
|
||||
i0.Value<int>? boundingBoxX2,
|
||||
i0.Value<int>? boundingBoxY2,
|
||||
i0.Value<String>? sourceType,
|
||||
i0.Value<bool>? isVisible,
|
||||
i0.Value<DateTime?>? deletedAt,
|
||||
}) {
|
||||
return i1.AssetFaceEntityCompanion(
|
||||
id: id ?? this.id,
|
||||
@@ -1155,6 +1279,8 @@ class AssetFaceEntityCompanion
|
||||
boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2,
|
||||
boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1191,6 +1317,12 @@ class AssetFaceEntityCompanion
|
||||
if (sourceType.present) {
|
||||
map['source_type'] = i0.Variable<String>(sourceType.value);
|
||||
}
|
||||
if (isVisible.present) {
|
||||
map['is_visible'] = i0.Variable<bool>(isVisible.value);
|
||||
}
|
||||
if (deletedAt.present) {
|
||||
map['deleted_at'] = i0.Variable<DateTime>(deletedAt.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1206,7 +1338,9 @@ class AssetFaceEntityCompanion
|
||||
..write('boundingBoxY1: $boundingBoxY1, ')
|
||||
..write('boundingBoxX2: $boundingBoxX2, ')
|
||||
..write('boundingBoxY2: $boundingBoxY2, ')
|
||||
..write('sourceType: $sourceType')
|
||||
..write('sourceType: $sourceType, ')
|
||||
..write('isVisible: $isVisible, ')
|
||||
..write('deletedAt: $deletedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 19;
|
||||
int get schemaVersion => 20;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -226,6 +226,10 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
|
||||
await m.createIndex(v19.idxStackPrimaryAssetId);
|
||||
},
|
||||
from19To20: (m, v20) async {
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8360,6 +8360,550 @@ final class Schema19 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema20 extends i0.VersionedSchema {
|
||||
Schema20({required super.database}) : super(version: 20);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxRemoteAlbumOwnerId,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetLocalDateTimeDay,
|
||||
idxRemoteAssetLocalDateTimeMonth,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape28 remoteAssetEntity = Shape28(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
_column_101,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape26 localAssetEntity = Shape26(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_98,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index 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 idxRemoteAlbumOwnerId = i1.Index(
|
||||
'idx_remote_album_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_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 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 idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
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 idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||
'idx_remote_asset_local_date_time_day',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||
'idx_remote_asset_local_date_time_month',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_99,
|
||||
_column_100,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape29 assetFaceEntity = Shape29(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
_column_102,
|
||||
_column_18,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape25 trashedLocalAssetEntity = Shape25(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_97,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index 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 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 idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape29 extends i0.VersionedTable {
|
||||
Shape29({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get assetId =>
|
||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get personId =>
|
||||
columnsByName['person_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get imageWidth =>
|
||||
columnsByName['image_width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get imageHeight =>
|
||||
columnsByName['image_height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxX1 =>
|
||||
columnsByName['bounding_box_x1']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxY1 =>
|
||||
columnsByName['bounding_box_y1']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxX2 =>
|
||||
columnsByName['bounding_box_x2']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get boundingBoxY2 =>
|
||||
columnsByName['bounding_box_y2']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get sourceType =>
|
||||
columnsByName['source_type']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isVisible =>
|
||||
columnsByName['is_visible']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<DateTime> get deletedAt =>
|
||||
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>(
|
||||
'is_visible',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_visible" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const CustomExpression('1'),
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -8379,6 +8923,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -8472,6 +9017,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from18To19(migrator, schema);
|
||||
return 19;
|
||||
case 19:
|
||||
final schema = Schema20(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from19To20(migrator, schema);
|
||||
return 20;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -8497,6 +9047,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -8517,5 +9068,6 @@ i1.OnUpgrade stepByStep({
|
||||
from16To17: from16To17,
|
||||
from17To18: from17To18,
|
||||
from18To19: from18To19,
|
||||
from19To20: from19To20,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -184,7 +184,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
if (keepFavorites) {
|
||||
whereClause = whereClause & _db.localAssetEntity.isFavorite.equals(false);
|
||||
whereClause =
|
||||
whereClause & _db.localAssetEntity.isFavorite.equals(false) & _db.remoteAssetEntity.isFavorite.equals(false);
|
||||
}
|
||||
|
||||
query.where(whereClause);
|
||||
|
||||
@@ -16,9 +16,15 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
|
||||
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
|
||||
final query = _db.select(_db.assetFaceEntity).join([
|
||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||
])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false));
|
||||
final query =
|
||||
_db.select(_db.assetFaceEntity).join([
|
||||
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
|
||||
])..where(
|
||||
_db.assetFaceEntity.assetId.equals(assetId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull() &
|
||||
_db.personEntity.isHidden.equals(false),
|
||||
);
|
||||
|
||||
return query.map((row) {
|
||||
final person = row.readTable(_db.personEntity);
|
||||
@@ -39,7 +45,9 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
..where(
|
||||
people.isHidden.equals(false) &
|
||||
assets.deletedAt.isNull() &
|
||||
assets.visibility.equalsValue(AssetVisibility.timeline),
|
||||
assets.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
faces.isVisible.equals(true) &
|
||||
faces.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([people.id], having: faces.id.count().isBiggerOrEqualValue(3) | people.name.equals('').not())
|
||||
..orderBy([
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
@@ -321,26 +323,32 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
}).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
|
||||
..join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
]);
|
||||
Future<List<String>> getSortedAlbumIds(List<String> albumIds, {required AssetDateAggregation aggregation}) async {
|
||||
if (albumIds.isEmpty) return [];
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
|
||||
}
|
||||
final jsonIds = jsonEncode(albumIds);
|
||||
final sqlAgg = aggregation == AssetDateAggregation.start ? 'MIN' : 'MAX';
|
||||
|
||||
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
|
||||
..join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
]);
|
||||
final rows = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT
|
||||
raae.album_id,
|
||||
$sqlAgg(rae.local_date_time) AS asset_date
|
||||
FROM json_each(?) ids
|
||||
INNER JOIN remote_album_asset_entity raae
|
||||
ON raae.album_id = ids.value
|
||||
INNER JOIN remote_asset_entity rae
|
||||
ON rae.id = raae.asset_id
|
||||
GROUP BY raae.album_id
|
||||
ORDER BY asset_date ASC
|
||||
''',
|
||||
variables: [Variable<String>(jsonIds)],
|
||||
readsFrom: {_db.remoteAlbumAssetEntity, _db.remoteAssetEntity},
|
||||
)
|
||||
.get();
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
|
||||
return rows.map((row) => row.read<String>('album_id')).toList();
|
||||
}
|
||||
|
||||
Future<int> getCount() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -25,6 +26,7 @@ class SyncApiRepository {
|
||||
|
||||
Future<void> streamChanges(
|
||||
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
|
||||
required SemVer serverVersion,
|
||||
Function()? onReset,
|
||||
int batchSize = kSyncEventBatchSize,
|
||||
http.Client? httpClient,
|
||||
@@ -64,7 +66,8 @@ class SyncApiRepository {
|
||||
SyncRequestType.partnerStacksV1,
|
||||
SyncRequestType.userMetadataV1,
|
||||
SyncRequestType.peopleV1,
|
||||
SyncRequestType.assetFacesV1,
|
||||
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2,
|
||||
],
|
||||
reset: shouldReset,
|
||||
).toJson(),
|
||||
@@ -190,6 +193,7 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.personV1: SyncPersonV1.fromJson,
|
||||
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
|
||||
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
|
||||
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
|
||||
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
|
||||
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
|
||||
};
|
||||
|
||||
@@ -652,6 +652,37 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetFacesV2(Iterable<SyncAssetFaceV2> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetFace in data) {
|
||||
final companion = AssetFaceEntityCompanion(
|
||||
assetId: Value(assetFace.assetId),
|
||||
personId: Value(assetFace.personId),
|
||||
imageWidth: Value(assetFace.imageWidth),
|
||||
imageHeight: Value(assetFace.imageHeight),
|
||||
boundingBoxX1: Value(assetFace.boundingBoxX1),
|
||||
boundingBoxY1: Value(assetFace.boundingBoxY1),
|
||||
boundingBoxX2: Value(assetFace.boundingBoxX2),
|
||||
boundingBoxY2: Value(assetFace.boundingBoxY2),
|
||||
sourceType: Value(assetFace.sourceType),
|
||||
deletedAt: Value(assetFace.deletedAt),
|
||||
isVisible: Value(assetFace.isVisible),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.assetFaceEntity,
|
||||
companion.copyWith(id: Value(assetFace.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetFacesV2', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAssetFacesV1(Iterable<SyncAssetFaceDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
|
||||
@@ -323,6 +323,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
|
||||
groupBy: groupBy,
|
||||
origin: TimelineOrigin.archive,
|
||||
joinLocal: true,
|
||||
);
|
||||
|
||||
TimelineQuery locked(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
|
||||
@@ -421,7 +422,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.assetFaceEntity.personId.equals(personId),
|
||||
_db.assetFaceEntity.personId.equals(personId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
);
|
||||
|
||||
return query.map((row) {
|
||||
@@ -446,7 +449,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.assetFaceEntity.personId.equals(personId),
|
||||
_db.assetFaceEntity.personId.equals(personId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
@@ -476,7 +481,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
_db.assetFaceEntity.personId.equals(personId),
|
||||
_db.assetFaceEntity.personId.equals(personId) &
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
@@ -109,9 +109,43 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (context.router.current.name == SplashScreenRoute.name) {
|
||||
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
|
||||
if (needBetaMigration) {
|
||||
bool migrate =
|
||||
(await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("New Timeline Experience"),
|
||||
content: const Text(
|
||||
"The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
|
||||
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
|
||||
],
|
||||
),
|
||||
)) ??
|
||||
false;
|
||||
if (migrate != true) {
|
||||
migrate =
|
||||
(await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Are you sure?"),
|
||||
content: const Text(
|
||||
"If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
|
||||
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
|
||||
],
|
||||
),
|
||||
)) ??
|
||||
false;
|
||||
}
|
||||
await Store.put(StoreKey.needBetaMigration, false);
|
||||
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
|
||||
return;
|
||||
if (migrate) {
|
||||
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
@@ -22,6 +23,18 @@ class DeleteTrashActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
|
||||
|
||||
final confirmDelete =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => TrashDeleteDialog(count: selectCount),
|
||||
) ??
|
||||
false;
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
double _lastScrollOffset = 0.0;
|
||||
|
||||
DragStartDetails? _dragStart;
|
||||
_DragIntent _dragIntent = _DragIntent.none;
|
||||
@@ -95,7 +94,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
void _showDetails() {
|
||||
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
|
||||
_lastScrollOffset = _proxyScrollController.offset;
|
||||
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
@@ -109,17 +107,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
void _onScroll() {
|
||||
final offset = _proxyScrollController.offset;
|
||||
if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) {
|
||||
if (offset > SnapScrollPhysics.minSnapDistance) {
|
||||
_viewer.setShowingDetails(true);
|
||||
} else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) {
|
||||
_viewer.setShowingDetails(false);
|
||||
}
|
||||
_lastScrollOffset = offset;
|
||||
}
|
||||
|
||||
void _beginDrag(DragStartDetails details) {
|
||||
_dragStart = details;
|
||||
_lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0;
|
||||
|
||||
if (_viewController != null) {
|
||||
_initialPhotoViewState = _viewController!.value;
|
||||
|
||||
@@ -25,7 +25,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDate = widget.person.birthDate ?? DateTime.now();
|
||||
_selectedDate = widget.person.birthDate ?? DateTime(DateTime.now().year - 30, 1, 1);
|
||||
}
|
||||
|
||||
void saveBirthday() async {
|
||||
@@ -90,6 +90,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
selectedDate: _selectedDate,
|
||||
locale: context.locale,
|
||||
minimumDate: DateTime(1800, 1, 1),
|
||||
maximumDate: DateTime.now(),
|
||||
onDateTimeChanged: (DateTime value) {
|
||||
setState(() {
|
||||
_selectedDate = value;
|
||||
|
||||
@@ -29,38 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
class _TimelineRestorationState extends ChangeNotifier {
|
||||
int? _restoreAssetIndex;
|
||||
bool _shouldRestoreAssetPosition = false;
|
||||
|
||||
int? get restoreAssetIndex => _restoreAssetIndex;
|
||||
bool get shouldRestoreAssetPosition => _shouldRestoreAssetPosition;
|
||||
|
||||
void setRestoreAssetIndex(int? index) {
|
||||
_restoreAssetIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setShouldRestoreAssetPosition(bool should) {
|
||||
_shouldRestoreAssetPosition = should;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearRestoreAssetIndex() {
|
||||
_restoreAssetIndex = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineRestorationProvider extends InheritedNotifier<_TimelineRestorationState> {
|
||||
const _TimelineRestorationProvider({required super.notifier, required super.child});
|
||||
|
||||
static _TimelineRestorationState of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<_TimelineRestorationProvider>()!.notifier!;
|
||||
}
|
||||
}
|
||||
|
||||
class Timeline extends StatefulWidget {
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -90,68 +59,38 @@ class Timeline extends StatefulWidget {
|
||||
final bool readOnly;
|
||||
final bool persistentBottomBar;
|
||||
|
||||
@override
|
||||
State<Timeline> createState() => _TimelineState();
|
||||
}
|
||||
|
||||
class _TimelineState extends State<Timeline> {
|
||||
double? _lastWidth;
|
||||
late final _TimelineRestorationState _restorationState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restorationState = _TimelineRestorationState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_restorationState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
if (_lastWidth != null && _lastWidth != constraints.maxWidth) {
|
||||
_restorationState.setShouldRestoreAssetPosition(true);
|
||||
}
|
||||
_lastWidth = constraints.maxWidth;
|
||||
return _TimelineRestorationProvider(
|
||||
notifier: _restorationState,
|
||||
child: ProviderScope(
|
||||
key: ValueKey(_lastWidth),
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
withStack: widget.withStack,
|
||||
groupBy: widget.groupBy,
|
||||
),
|
||||
),
|
||||
if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
key: const ValueKey('_sliver_timeline'),
|
||||
topSliverWidget: widget.topSliverWidget,
|
||||
topSliverWidgetHeight: widget.topSliverWidgetHeight,
|
||||
appBar: widget.appBar,
|
||||
bottomSheet: widget.bottomSheet,
|
||||
withScrubber: widget.withScrubber,
|
||||
persistentBottomBar: widget.persistentBottomBar,
|
||||
snapToMonth: widget.snapToMonth,
|
||||
initialScrollOffset: widget.initialScrollOffset,
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
timelineArgsProvider.overrideWith(
|
||||
(ref) => TimelineArgs(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||
],
|
||||
child: _SliverTimeline(
|
||||
topSliverWidget: topSliverWidget,
|
||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
persistentBottomBar: persistentBottomBar,
|
||||
snapToMonth: snapToMonth,
|
||||
initialScrollOffset: initialScrollOffset,
|
||||
maxWidth: constraints.maxWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -170,7 +109,6 @@ class _AlwaysReadOnlyNotifier extends ReadOnlyModeNotifier {
|
||||
|
||||
class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
const _SliverTimeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
this.topSliverWidgetHeight,
|
||||
this.appBar,
|
||||
@@ -179,6 +117,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
this.persistentBottomBar = false,
|
||||
this.snapToMonth = true,
|
||||
this.initialScrollOffset,
|
||||
this.maxWidth,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -189,6 +128,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
final bool persistentBottomBar;
|
||||
final bool snapToMonth;
|
||||
final double? initialScrollOffset;
|
||||
final double? maxWidth;
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SliverTimelineState();
|
||||
@@ -207,6 +147,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
int _perRow = 4;
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
int? _restoreAssetIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -225,6 +166,20 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SliverTimeline oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.maxWidth != oldWidget.maxWidth) {
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final index = _getCurrentAssetIndex(segments);
|
||||
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position
|
||||
final _ = ref.refresh(timelineArgsProvider);
|
||||
_restoreAssetIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
switch (event) {
|
||||
case ScrollToTopEvent():
|
||||
@@ -242,21 +197,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onMultiSelectionToggled(_, bool isEnabled) {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
void _restoreAssetPosition(_) {
|
||||
final restorationState = _TimelineRestorationProvider.of(context);
|
||||
if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return;
|
||||
if (_restoreAssetIndex == null) return;
|
||||
|
||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||
asyncSegments.whenData((segments) {
|
||||
final targetSegment = segments.lastWhereOrNull(
|
||||
(segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!,
|
||||
);
|
||||
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
|
||||
if (targetSegment != null) {
|
||||
final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
|
||||
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
|
||||
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
|
||||
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
|
||||
@@ -268,7 +216,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
});
|
||||
}
|
||||
});
|
||||
restorationState.clearRestoreAssetIndex();
|
||||
_restoreAssetIndex = null;
|
||||
}
|
||||
|
||||
void _onMultiSelectionToggled(_, bool isEnabled) {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) {
|
||||
@@ -478,67 +430,56 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: NotificationListener<ScrollEndNotification>(
|
||||
onNotification: (notification) {
|
||||
final currentIndex = _getCurrentAssetIndex(segments);
|
||||
if (currentIndex != null && mounted) {
|
||||
_TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
child: RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final restorationState = _TimelineRestorationProvider.of(context);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
});
|
||||
|
||||
restorationState.setRestoreAssetIndex(targetAssetIndex);
|
||||
restorationState.setShouldRestoreAssetPosition(true);
|
||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow);
|
||||
}
|
||||
};
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
timeline,
|
||||
if (isMultiSelectStatusVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
onAssetEnter: _handleDragAssetEnter,
|
||||
onEnd: !isReadonlyModeEnabled ? _stopDrag : null,
|
||||
onScroll: _dragScroll,
|
||||
onScrollStart: () {
|
||||
// Minimize the bottom sheet when drag selection starts
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top,
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -30,11 +30,10 @@ import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 21;
|
||||
const int targetVersion = 22;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -100,6 +99,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||
await Store.put(StoreKey.needBetaMigration, true);
|
||||
}
|
||||
|
||||
if (targetVersion >= 12) {
|
||||
await Store.put(StoreKey.version, targetVersion);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
sealed class Option<T> {
|
||||
const Option();
|
||||
|
||||
const factory Option.some(T value) = Some<T>;
|
||||
|
||||
const factory Option.none() = None<T>;
|
||||
|
||||
factory Option.fromNullable(T? value) => value != null ? Some(value) : None<T>();
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
bool get isSome => this is Some<T>;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
bool get isNone => this is None<T>;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
T? get unwrapOrNull => switch (this) {
|
||||
Some(:final value) => value,
|
||||
None() => null,
|
||||
};
|
||||
|
||||
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||
Some(:final value) => onSome(value),
|
||||
None() => onNone(),
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => switch (this) {
|
||||
Some(:final value) => 'Some($value)',
|
||||
None() => 'None',
|
||||
};
|
||||
}
|
||||
|
||||
final class Some<T> extends Option<T> {
|
||||
final T value;
|
||||
|
||||
const Some(this.value);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is Some<T> && other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
}
|
||||
|
||||
final class None<T> extends Option<T> {
|
||||
const None();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is None<T>;
|
||||
|
||||
@override
|
||||
int get hashCode => 0;
|
||||
}
|
||||
|
||||
extension ObjectOptionExtension<T> on T? {
|
||||
Option<T> toOption() => Option.fromNullable(this);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class TrashDeleteDialog extends StatelessWidget {
|
||||
const TrashDeleteDialog({super.key, required this.count});
|
||||
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
title: Text(context.t.permanently_delete),
|
||||
content: ImmichFormattedText(context.t.permanently_delete_assets_prompt(count: count)),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: () => context.pop(false),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.surfaceDim,
|
||||
foregroundColor: context.primaryColor,
|
||||
),
|
||||
child: Text(context.t.cancel, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
|
||||
child: FilledButton(
|
||||
onPressed: () => context.pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
foregroundColor: context.colorScheme.onErrorContainer,
|
||||
),
|
||||
child: Text(context.t.delete, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
|
||||
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(),
|
||||
),
|
||||
const BetaTimelineListTile(),
|
||||
if (!Store.isBetaTimelineEnabled) const BetaTimelineListTile(),
|
||||
if (Store.isBetaTimelineEnabled)
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: readonlyModeEnabled,
|
||||
|
||||
Generated
+9
-9
@@ -421,14 +421,14 @@ class AssetsApi {
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [AssetEditActionListDto] assetEditActionListDto (required):
|
||||
Future<Response> editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async {
|
||||
/// * [AssetEditsCreateDto] assetEditsCreateDto (required):
|
||||
Future<Response> editAssetWithHttpInfo(String id, AssetEditsCreateDto assetEditsCreateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/edits'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetEditActionListDto;
|
||||
Object? postBody = assetEditsCreateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
@@ -456,9 +456,9 @@ class AssetsApi {
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [AssetEditActionListDto] assetEditActionListDto (required):
|
||||
Future<AssetEditsDto?> editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async {
|
||||
final response = await editAssetWithHttpInfo(id, assetEditActionListDto,);
|
||||
/// * [AssetEditsCreateDto] assetEditsCreateDto (required):
|
||||
Future<AssetEditsResponseDto?> editAsset(String id, AssetEditsCreateDto assetEditsCreateDto,) async {
|
||||
final response = await editAssetWithHttpInfo(id, assetEditsCreateDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -466,7 +466,7 @@ class AssetsApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsResponseDto',) as AssetEditsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
@@ -576,7 +576,7 @@ class AssetsApi {
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<AssetEditsDto?> getAssetEdits(String id,) async {
|
||||
Future<AssetEditsResponseDto?> getAssetEdits(String id,) async {
|
||||
final response = await getAssetEditsWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
@@ -585,7 +585,7 @@ class AssetsApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto;
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsResponseDto',) as AssetEditsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
|
||||
+21
-21
@@ -10,9 +10,9 @@
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditActionCrop {
|
||||
/// Returns a new [AssetEditActionCrop] instance.
|
||||
AssetEditActionCrop({
|
||||
class AssetEditActionItemDto {
|
||||
/// Returns a new [AssetEditActionItemDto] instance.
|
||||
AssetEditActionItemDto({
|
||||
required this.action,
|
||||
required this.parameters,
|
||||
});
|
||||
@@ -20,10 +20,10 @@ class AssetEditActionCrop {
|
||||
/// Type of edit action to perform
|
||||
AssetEditAction action;
|
||||
|
||||
CropParameters parameters;
|
||||
AssetEditActionItemDtoParameters parameters;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionCrop &&
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDto &&
|
||||
other.action == action &&
|
||||
other.parameters == parameters;
|
||||
|
||||
@@ -34,7 +34,7 @@ class AssetEditActionCrop {
|
||||
(parameters.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditActionCrop[action=$action, parameters=$parameters]';
|
||||
String toString() => 'AssetEditActionItemDto[action=$action, parameters=$parameters]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -43,27 +43,27 @@ class AssetEditActionCrop {
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditActionCrop] instance and imports its values from
|
||||
/// Returns a new [AssetEditActionItemDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditActionCrop? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionCrop");
|
||||
static AssetEditActionItemDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionItemDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionCrop(
|
||||
return AssetEditActionItemDto(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
parameters: CropParameters.fromJson(json[r'parameters'])!,
|
||||
parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditActionCrop> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionCrop>[];
|
||||
static List<AssetEditActionItemDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionItemDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditActionCrop.fromJson(row);
|
||||
final value = AssetEditActionItemDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
@@ -72,12 +72,12 @@ class AssetEditActionCrop {
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditActionCrop> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionCrop>{};
|
||||
static Map<String, AssetEditActionItemDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionItemDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditActionCrop.fromJson(entry.value);
|
||||
final value = AssetEditActionItemDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -86,14 +86,14 @@ class AssetEditActionCrop {
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditActionCrop-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionCrop>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionCrop>>{};
|
||||
// maps a json object with a list of AssetEditActionItemDto-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionItemDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditActionCrop.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = AssetEditActionItemDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditActionItemDtoParameters {
|
||||
/// Returns a new [AssetEditActionItemDtoParameters] instance.
|
||||
AssetEditActionItemDtoParameters({
|
||||
required this.height,
|
||||
required this.width,
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.angle,
|
||||
required this.axis,
|
||||
});
|
||||
|
||||
/// Height of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num height;
|
||||
|
||||
/// Width of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num width;
|
||||
|
||||
/// Top-Left X coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num x;
|
||||
|
||||
/// Top-Left Y coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num y;
|
||||
|
||||
/// Rotation angle in degrees
|
||||
num angle;
|
||||
|
||||
/// Axis to mirror along
|
||||
MirrorAxis axis;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemDtoParameters &&
|
||||
other.height == height &&
|
||||
other.width == width &&
|
||||
other.x == x &&
|
||||
other.y == y &&
|
||||
other.angle == angle &&
|
||||
other.axis == axis;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(height.hashCode) +
|
||||
(width.hashCode) +
|
||||
(x.hashCode) +
|
||||
(y.hashCode) +
|
||||
(angle.hashCode) +
|
||||
(axis.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditActionItemDtoParameters[height=$height, width=$width, x=$x, y=$y, angle=$angle, axis=$axis]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'height'] = this.height;
|
||||
json[r'width'] = this.width;
|
||||
json[r'x'] = this.x;
|
||||
json[r'y'] = this.y;
|
||||
json[r'angle'] = this.angle;
|
||||
json[r'axis'] = this.axis;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditActionItemDtoParameters] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditActionItemDtoParameters? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionItemDtoParameters");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionItemDtoParameters(
|
||||
height: num.parse('${json[r'height']}'),
|
||||
width: num.parse('${json[r'width']}'),
|
||||
x: num.parse('${json[r'x']}'),
|
||||
y: num.parse('${json[r'y']}'),
|
||||
angle: num.parse('${json[r'angle']}'),
|
||||
axis: MirrorAxis.fromJson(json[r'axis'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditActionItemDtoParameters> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionItemDtoParameters>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditActionItemDtoParameters.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditActionItemDtoParameters> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionItemDtoParameters>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditActionItemDtoParameters.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditActionItemDtoParameters-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionItemDtoParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionItemDtoParameters>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditActionItemDtoParameters.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'height',
|
||||
'width',
|
||||
'x',
|
||||
'y',
|
||||
'angle',
|
||||
'axis',
|
||||
};
|
||||
}
|
||||
|
||||
+29
-21
@@ -10,60 +10,67 @@
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditActionListDtoEditsInner {
|
||||
/// Returns a new [AssetEditActionListDtoEditsInner] instance.
|
||||
AssetEditActionListDtoEditsInner({
|
||||
class AssetEditActionItemResponseDto {
|
||||
/// Returns a new [AssetEditActionItemResponseDto] instance.
|
||||
AssetEditActionItemResponseDto({
|
||||
required this.action,
|
||||
required this.id,
|
||||
required this.parameters,
|
||||
});
|
||||
|
||||
/// Type of edit action to perform
|
||||
AssetEditAction action;
|
||||
|
||||
MirrorParameters parameters;
|
||||
String id;
|
||||
|
||||
AssetEditActionItemDtoParameters parameters;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionItemResponseDto &&
|
||||
other.action == action &&
|
||||
other.id == id &&
|
||||
other.parameters == parameters;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(action.hashCode) +
|
||||
(id.hashCode) +
|
||||
(parameters.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditActionListDtoEditsInner[action=$action, parameters=$parameters]';
|
||||
String toString() => 'AssetEditActionItemResponseDto[action=$action, id=$id, parameters=$parameters]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'action'] = this.action;
|
||||
json[r'id'] = this.id;
|
||||
json[r'parameters'] = this.parameters;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditActionListDtoEditsInner] instance and imports its values from
|
||||
/// Returns a new [AssetEditActionItemResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditActionListDtoEditsInner? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionListDtoEditsInner");
|
||||
static AssetEditActionItemResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionItemResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionListDtoEditsInner(
|
||||
return AssetEditActionItemResponseDto(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
parameters: AssetEditActionItemDtoParameters.fromJson(json[r'parameters'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditActionListDtoEditsInner> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionListDtoEditsInner>[];
|
||||
static List<AssetEditActionItemResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionItemResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditActionListDtoEditsInner.fromJson(row);
|
||||
final value = AssetEditActionItemResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
@@ -72,12 +79,12 @@ class AssetEditActionListDtoEditsInner {
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditActionListDtoEditsInner> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionListDtoEditsInner>{};
|
||||
static Map<String, AssetEditActionItemResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionItemResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditActionListDtoEditsInner.fromJson(entry.value);
|
||||
final value = AssetEditActionItemResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -86,14 +93,14 @@ class AssetEditActionListDtoEditsInner {
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditActionListDtoEditsInner-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionListDtoEditsInner>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionListDtoEditsInner>>{};
|
||||
// maps a json object with a list of AssetEditActionItemResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionItemResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionItemResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditActionListDtoEditsInner.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = AssetEditActionItemResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
@@ -102,6 +109,7 @@ class AssetEditActionListDtoEditsInner {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'action',
|
||||
'id',
|
||||
'parameters',
|
||||
};
|
||||
}
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditActionMirror {
|
||||
/// Returns a new [AssetEditActionMirror] instance.
|
||||
AssetEditActionMirror({
|
||||
required this.action,
|
||||
required this.parameters,
|
||||
});
|
||||
|
||||
/// Type of edit action to perform
|
||||
AssetEditAction action;
|
||||
|
||||
MirrorParameters parameters;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionMirror &&
|
||||
other.action == action &&
|
||||
other.parameters == parameters;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(action.hashCode) +
|
||||
(parameters.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditActionMirror[action=$action, parameters=$parameters]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'action'] = this.action;
|
||||
json[r'parameters'] = this.parameters;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditActionMirror] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditActionMirror? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionMirror");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionMirror(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditActionMirror> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionMirror>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditActionMirror.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditActionMirror> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionMirror>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditActionMirror.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditActionMirror-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionMirror>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionMirror>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditActionMirror.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'action',
|
||||
'parameters',
|
||||
};
|
||||
}
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditActionRotate {
|
||||
/// Returns a new [AssetEditActionRotate] instance.
|
||||
AssetEditActionRotate({
|
||||
required this.action,
|
||||
required this.parameters,
|
||||
});
|
||||
|
||||
/// Type of edit action to perform
|
||||
AssetEditAction action;
|
||||
|
||||
RotateParameters parameters;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionRotate &&
|
||||
other.action == action &&
|
||||
other.parameters == parameters;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(action.hashCode) +
|
||||
(parameters.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditActionRotate[action=$action, parameters=$parameters]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'action'] = this.action;
|
||||
json[r'parameters'] = this.parameters;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditActionRotate] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditActionRotate? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionRotate");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionRotate(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
parameters: RotateParameters.fromJson(json[r'parameters'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditActionRotate> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionRotate>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditActionRotate.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditActionRotate> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionRotate>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditActionRotate.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditActionRotate-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionRotate>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionRotate>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditActionRotate.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'action',
|
||||
'parameters',
|
||||
};
|
||||
}
|
||||
|
||||
+21
-21
@@ -10,17 +10,17 @@
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditActionListDto {
|
||||
/// Returns a new [AssetEditActionListDto] instance.
|
||||
AssetEditActionListDto({
|
||||
class AssetEditsCreateDto {
|
||||
/// Returns a new [AssetEditsCreateDto] instance.
|
||||
AssetEditsCreateDto({
|
||||
this.edits = const [],
|
||||
});
|
||||
|
||||
/// List of edit actions to apply (crop, rotate, or mirror)
|
||||
List<AssetEditActionListDtoEditsInner> edits;
|
||||
List<AssetEditActionItemDto> edits;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDto &&
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditsCreateDto &&
|
||||
_deepEquality.equals(other.edits, edits);
|
||||
|
||||
@override
|
||||
@@ -29,7 +29,7 @@ class AssetEditActionListDto {
|
||||
(edits.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditActionListDto[edits=$edits]';
|
||||
String toString() => 'AssetEditsCreateDto[edits=$edits]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -37,26 +37,26 @@ class AssetEditActionListDto {
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditActionListDto] instance and imports its values from
|
||||
/// Returns a new [AssetEditsCreateDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditActionListDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditActionListDto");
|
||||
static AssetEditsCreateDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditsCreateDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionListDto(
|
||||
edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']),
|
||||
return AssetEditsCreateDto(
|
||||
edits: AssetEditActionItemDto.listFromJson(json[r'edits']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditActionListDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditActionListDto>[];
|
||||
static List<AssetEditsCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditsCreateDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditActionListDto.fromJson(row);
|
||||
final value = AssetEditsCreateDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
@@ -65,12 +65,12 @@ class AssetEditActionListDto {
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditActionListDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditActionListDto>{};
|
||||
static Map<String, AssetEditsCreateDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditsCreateDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditActionListDto.fromJson(entry.value);
|
||||
final value = AssetEditsCreateDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -79,14 +79,14 @@ class AssetEditActionListDto {
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditActionListDto-objects as value to a dart map
|
||||
static Map<String, List<AssetEditActionListDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditActionListDto>>{};
|
||||
// maps a json object with a list of AssetEditsCreateDto-objects as value to a dart map
|
||||
static Map<String, List<AssetEditsCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditsCreateDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditActionListDto.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = AssetEditsCreateDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
+23
-23
@@ -10,21 +10,21 @@
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetEditsDto {
|
||||
/// Returns a new [AssetEditsDto] instance.
|
||||
AssetEditsDto({
|
||||
class AssetEditsResponseDto {
|
||||
/// Returns a new [AssetEditsResponseDto] instance.
|
||||
AssetEditsResponseDto({
|
||||
required this.assetId,
|
||||
this.edits = const [],
|
||||
});
|
||||
|
||||
/// Asset ID to apply edits to
|
||||
/// Asset ID these edits belong to
|
||||
String assetId;
|
||||
|
||||
/// List of edit actions to apply (crop, rotate, or mirror)
|
||||
List<AssetEditActionListDtoEditsInner> edits;
|
||||
/// List of edit actions applied to the asset
|
||||
List<AssetEditActionItemResponseDto> edits;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto &&
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetEditsResponseDto &&
|
||||
other.assetId == assetId &&
|
||||
_deepEquality.equals(other.edits, edits);
|
||||
|
||||
@@ -35,7 +35,7 @@ class AssetEditsDto {
|
||||
(edits.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]';
|
||||
String toString() => 'AssetEditsResponseDto[assetId=$assetId, edits=$edits]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -44,27 +44,27 @@ class AssetEditsDto {
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetEditsDto] instance and imports its values from
|
||||
/// Returns a new [AssetEditsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetEditsDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditsDto");
|
||||
static AssetEditsResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AssetEditsResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditsDto(
|
||||
return AssetEditsResponseDto(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']),
|
||||
edits: AssetEditActionItemResponseDto.listFromJson(json[r'edits']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetEditsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditsDto>[];
|
||||
static List<AssetEditsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetEditsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetEditsDto.fromJson(row);
|
||||
final value = AssetEditsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
@@ -73,12 +73,12 @@ class AssetEditsDto {
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetEditsDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditsDto>{};
|
||||
static Map<String, AssetEditsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetEditsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetEditsDto.fromJson(entry.value);
|
||||
final value = AssetEditsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -87,14 +87,14 @@ class AssetEditsDto {
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetEditsDto-objects as value to a dart map
|
||||
static Map<String, List<AssetEditsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditsDto>>{};
|
||||
// maps a json object with a list of AssetEditsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetEditsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetEditsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetEditsDto.listFromJson(entry.value, growable: growable,);
|
||||
map[entry.key] = AssetEditsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
+1
-1
@@ -156,7 +156,7 @@ class AssetResponseDto {
|
||||
|
||||
List<TagResponseDto> tags;
|
||||
|
||||
/// Thumbhash for thumbnail generation
|
||||
/// Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.
|
||||
String? thumbhash;
|
||||
|
||||
/// Asset type
|
||||
|
||||
+37
-1
@@ -15,9 +15,11 @@ class MemoryCreateDto {
|
||||
MemoryCreateDto({
|
||||
this.assetIds = const [],
|
||||
required this.data,
|
||||
this.hideAt,
|
||||
this.isSaved,
|
||||
required this.memoryAt,
|
||||
this.seenAt,
|
||||
this.showAt,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
@@ -26,6 +28,15 @@ class MemoryCreateDto {
|
||||
|
||||
OnThisDayDto data;
|
||||
|
||||
/// Date when memory should be hidden
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
DateTime? hideAt;
|
||||
|
||||
/// Is memory saved
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -47,6 +58,15 @@ class MemoryCreateDto {
|
||||
///
|
||||
DateTime? seenAt;
|
||||
|
||||
/// Date when memory should be shown
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
DateTime? showAt;
|
||||
|
||||
/// Memory type
|
||||
MemoryType type;
|
||||
|
||||
@@ -54,9 +74,11 @@ class MemoryCreateDto {
|
||||
bool operator ==(Object other) => identical(this, other) || other is MemoryCreateDto &&
|
||||
_deepEquality.equals(other.assetIds, assetIds) &&
|
||||
other.data == data &&
|
||||
other.hideAt == hideAt &&
|
||||
other.isSaved == isSaved &&
|
||||
other.memoryAt == memoryAt &&
|
||||
other.seenAt == seenAt &&
|
||||
other.showAt == showAt &&
|
||||
other.type == type;
|
||||
|
||||
@override
|
||||
@@ -64,18 +86,25 @@ class MemoryCreateDto {
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetIds.hashCode) +
|
||||
(data.hashCode) +
|
||||
(hideAt == null ? 0 : hideAt!.hashCode) +
|
||||
(isSaved == null ? 0 : isSaved!.hashCode) +
|
||||
(memoryAt.hashCode) +
|
||||
(seenAt == null ? 0 : seenAt!.hashCode) +
|
||||
(showAt == null ? 0 : showAt!.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MemoryCreateDto[assetIds=$assetIds, data=$data, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, type=$type]';
|
||||
String toString() => 'MemoryCreateDto[assetIds=$assetIds, data=$data, hideAt=$hideAt, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, showAt=$showAt, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetIds'] = this.assetIds;
|
||||
json[r'data'] = this.data;
|
||||
if (this.hideAt != null) {
|
||||
json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'hideAt'] = null;
|
||||
}
|
||||
if (this.isSaved != null) {
|
||||
json[r'isSaved'] = this.isSaved;
|
||||
} else {
|
||||
@@ -86,6 +115,11 @@ class MemoryCreateDto {
|
||||
json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'seenAt'] = null;
|
||||
}
|
||||
if (this.showAt != null) {
|
||||
json[r'showAt'] = this.showAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'showAt'] = null;
|
||||
}
|
||||
json[r'type'] = this.type;
|
||||
return json;
|
||||
@@ -104,9 +138,11 @@ class MemoryCreateDto {
|
||||
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
data: OnThisDayDto.fromJson(json[r'data'])!,
|
||||
hideAt: mapDateTime(json, r'hideAt', r''),
|
||||
isSaved: mapValueOfType<bool>(json, r'isSaved'),
|
||||
memoryAt: mapDateTime(json, r'memoryAt', r'')!,
|
||||
seenAt: mapDateTime(json, r'seenAt', r''),
|
||||
showAt: mapDateTime(json, r'showAt', r''),
|
||||
type: MemoryType.fromJson(json[r'type'])!,
|
||||
);
|
||||
}
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SyncAssetFaceV2 {
|
||||
/// Returns a new [SyncAssetFaceV2] instance.
|
||||
SyncAssetFaceV2({
|
||||
required this.assetId,
|
||||
required this.boundingBoxX1,
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY1,
|
||||
required this.boundingBoxY2,
|
||||
required this.deletedAt,
|
||||
required this.id,
|
||||
required this.imageHeight,
|
||||
required this.imageWidth,
|
||||
required this.isVisible,
|
||||
required this.personId,
|
||||
required this.sourceType,
|
||||
});
|
||||
|
||||
/// Asset ID
|
||||
String assetId;
|
||||
|
||||
int boundingBoxX1;
|
||||
|
||||
int boundingBoxX2;
|
||||
|
||||
int boundingBoxY1;
|
||||
|
||||
int boundingBoxY2;
|
||||
|
||||
/// Face deleted at
|
||||
DateTime? deletedAt;
|
||||
|
||||
/// Asset face ID
|
||||
String id;
|
||||
|
||||
int imageHeight;
|
||||
|
||||
int imageWidth;
|
||||
|
||||
/// Is the face visible in the asset
|
||||
bool isVisible;
|
||||
|
||||
/// Person ID
|
||||
String? personId;
|
||||
|
||||
/// Source type
|
||||
String sourceType;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetFaceV2 &&
|
||||
other.assetId == assetId &&
|
||||
other.boundingBoxX1 == boundingBoxX1 &&
|
||||
other.boundingBoxX2 == boundingBoxX2 &&
|
||||
other.boundingBoxY1 == boundingBoxY1 &&
|
||||
other.boundingBoxY2 == boundingBoxY2 &&
|
||||
other.deletedAt == deletedAt &&
|
||||
other.id == id &&
|
||||
other.imageHeight == imageHeight &&
|
||||
other.imageWidth == imageWidth &&
|
||||
other.isVisible == isVisible &&
|
||||
other.personId == personId &&
|
||||
other.sourceType == sourceType;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetId.hashCode) +
|
||||
(boundingBoxX1.hashCode) +
|
||||
(boundingBoxX2.hashCode) +
|
||||
(boundingBoxY1.hashCode) +
|
||||
(boundingBoxY2.hashCode) +
|
||||
(deletedAt == null ? 0 : deletedAt!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(imageHeight.hashCode) +
|
||||
(imageWidth.hashCode) +
|
||||
(isVisible.hashCode) +
|
||||
(personId == null ? 0 : personId!.hashCode) +
|
||||
(sourceType.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetId'] = this.assetId;
|
||||
json[r'boundingBoxX1'] = this.boundingBoxX1;
|
||||
json[r'boundingBoxX2'] = this.boundingBoxX2;
|
||||
json[r'boundingBoxY1'] = this.boundingBoxY1;
|
||||
json[r'boundingBoxY2'] = this.boundingBoxY2;
|
||||
if (this.deletedAt != null) {
|
||||
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'deletedAt'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'imageHeight'] = this.imageHeight;
|
||||
json[r'imageWidth'] = this.imageWidth;
|
||||
json[r'isVisible'] = this.isVisible;
|
||||
if (this.personId != null) {
|
||||
json[r'personId'] = this.personId;
|
||||
} else {
|
||||
// json[r'personId'] = null;
|
||||
}
|
||||
json[r'sourceType'] = this.sourceType;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAssetFaceV2] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAssetFaceV2? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAssetFaceV2");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAssetFaceV2(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
|
||||
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
|
||||
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
|
||||
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
|
||||
deletedAt: mapDateTime(json, r'deletedAt', r''),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
|
||||
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
|
||||
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
|
||||
personId: mapValueOfType<String>(json, r'personId'),
|
||||
sourceType: mapValueOfType<String>(json, r'sourceType')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAssetFaceV2> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAssetFaceV2>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAssetFaceV2.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAssetFaceV2> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAssetFaceV2>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAssetFaceV2.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAssetFaceV2-objects as value to a dart map
|
||||
static Map<String, List<SyncAssetFaceV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAssetFaceV2>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAssetFaceV2.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetId',
|
||||
'boundingBoxX1',
|
||||
'boundingBoxX2',
|
||||
'boundingBoxY1',
|
||||
'boundingBoxY2',
|
||||
'deletedAt',
|
||||
'id',
|
||||
'imageHeight',
|
||||
'imageWidth',
|
||||
'isVisible',
|
||||
'personId',
|
||||
'sourceType',
|
||||
};
|
||||
}
|
||||
|
||||
+3
@@ -64,6 +64,7 @@ class SyncEntityType {
|
||||
static const personV1 = SyncEntityType._(r'PersonV1');
|
||||
static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1');
|
||||
static const assetFaceV1 = SyncEntityType._(r'AssetFaceV1');
|
||||
static const assetFaceV2 = SyncEntityType._(r'AssetFaceV2');
|
||||
static const assetFaceDeleteV1 = SyncEntityType._(r'AssetFaceDeleteV1');
|
||||
static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1');
|
||||
static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1');
|
||||
@@ -114,6 +115,7 @@ class SyncEntityType {
|
||||
personV1,
|
||||
personDeleteV1,
|
||||
assetFaceV1,
|
||||
assetFaceV2,
|
||||
assetFaceDeleteV1,
|
||||
userMetadataV1,
|
||||
userMetadataDeleteV1,
|
||||
@@ -199,6 +201,7 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'PersonV1': return SyncEntityType.personV1;
|
||||
case r'PersonDeleteV1': return SyncEntityType.personDeleteV1;
|
||||
case r'AssetFaceV1': return SyncEntityType.assetFaceV1;
|
||||
case r'AssetFaceV2': return SyncEntityType.assetFaceV2;
|
||||
case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1;
|
||||
case r'UserMetadataV1': return SyncEntityType.userMetadataV1;
|
||||
case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1;
|
||||
|
||||
+3
@@ -42,6 +42,7 @@ class SyncRequestType {
|
||||
static const usersV1 = SyncRequestType._(r'UsersV1');
|
||||
static const peopleV1 = SyncRequestType._(r'PeopleV1');
|
||||
static const assetFacesV1 = SyncRequestType._(r'AssetFacesV1');
|
||||
static const assetFacesV2 = SyncRequestType._(r'AssetFacesV2');
|
||||
static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1');
|
||||
|
||||
/// List of all possible values in this [enum][SyncRequestType].
|
||||
@@ -65,6 +66,7 @@ class SyncRequestType {
|
||||
usersV1,
|
||||
peopleV1,
|
||||
assetFacesV1,
|
||||
assetFacesV2,
|
||||
userMetadataV1,
|
||||
];
|
||||
|
||||
@@ -123,6 +125,7 @@ class SyncRequestTypeTypeTransformer {
|
||||
case r'UsersV1': return SyncRequestType.usersV1;
|
||||
case r'PeopleV1': return SyncRequestType.peopleV1;
|
||||
case r'AssetFacesV1': return SyncRequestType.assetFacesV1;
|
||||
case r'AssetFacesV2': return SyncRequestType.assetFacesV2;
|
||||
case r'UserMetadataV1': return SyncRequestType.userMetadataV1;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export 'src/components/close_button.dart';
|
||||
export 'src/components/form.dart';
|
||||
export 'src/components/html_text.dart';
|
||||
export 'src/components/formatted_text.dart';
|
||||
export 'src/components/icon_button.dart';
|
||||
export 'src/components/password_input.dart';
|
||||
export 'src/components/text_button.dart';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FormattedSpan {
|
||||
final TextStyle? style;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const FormattedSpan({this.style, this.onTap});
|
||||
}
|
||||
|
||||
/// A widget that renders text with optional HTML-style formatting.
|
||||
///
|
||||
/// Supports the following tags:
|
||||
/// - `<b>` for bold text
|
||||
/// - `<link>` or any tag ending with `-link` for tappable links
|
||||
///
|
||||
/// Tags must not be nested. Each tag is matched independently left-to-right.
|
||||
///
|
||||
/// By default, `<b>` renders as [FontWeight.bold] and link tags render with an
|
||||
/// underline and no tap handler. Provide [spanBuilder] to attach tap callbacks
|
||||
/// or override styles per tag.
|
||||
///
|
||||
/// Bold-only example (no [spanBuilder] needed):
|
||||
/// ```dart
|
||||
/// ImmichFormattedText('Delete <b>{count}</b> items?')
|
||||
/// ```
|
||||
///
|
||||
/// Link example:
|
||||
/// ```dart
|
||||
/// ImmichFormattedText(
|
||||
/// 'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
|
||||
/// spanBuilder: (tag) => FormattedSpan(
|
||||
/// onTap: switch (tag) {
|
||||
/// 'docs-link' => () => launchUrl(docsUrl),
|
||||
/// 'other-link' => () => launchUrl(otherUrl),
|
||||
/// _ => null,
|
||||
/// },
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ImmichFormattedText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final bool? softWrap;
|
||||
final FormattedSpan Function(String tag)? spanBuilder;
|
||||
|
||||
const ImmichFormattedText(
|
||||
this.text, {
|
||||
this.spanBuilder,
|
||||
super.key,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.softWrap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichFormattedText> createState() => _ImmichFormattedTextState();
|
||||
}
|
||||
|
||||
class _ImmichFormattedTextState extends State<ImmichFormattedText> {
|
||||
final _recognizers = <GestureRecognizer>[];
|
||||
|
||||
// Matches <b>, <link>, or any *-link tag and its content.
|
||||
static final _tagPattern = RegExp(r'<(b|link|[\w]+-link)>(.*?)</\1>', caseSensitive: false, dotAll: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildSpans() {
|
||||
_disposeRecognizers();
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
int cursor = 0;
|
||||
|
||||
for (final match in _tagPattern.allMatches(widget.text)) {
|
||||
if (match.start > cursor) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
|
||||
}
|
||||
|
||||
final tag = match.group(1)!.toLowerCase();
|
||||
final content = match.group(2)!;
|
||||
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
|
||||
final style = formattedSpan.style ?? _defaultTextStyle(tag);
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (formattedSpan.onTap != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
|
||||
|
||||
cursor = match.end;
|
||||
}
|
||||
|
||||
if (cursor < widget.text.length) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor)));
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ => const FormattedSpan(),
|
||||
};
|
||||
|
||||
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
|
||||
'b' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'link' => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildSpans()),
|
||||
textAlign: widget.textAlign,
|
||||
overflow: widget.overflow,
|
||||
maxLines: widget.maxLines,
|
||||
softWrap: widget.softWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
enum _HtmlTagType {
|
||||
bold,
|
||||
link,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
class _HtmlTag {
|
||||
final _HtmlTagType type;
|
||||
final String tagName;
|
||||
|
||||
const _HtmlTag._({required this.type, required this.tagName});
|
||||
|
||||
static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported');
|
||||
|
||||
static _HtmlTag? fromString(dom.Node node) {
|
||||
final tagName = (node is dom.Element) ? node.localName : null;
|
||||
if (tagName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tag = tagName.toLowerCase();
|
||||
return switch (tag) {
|
||||
'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag),
|
||||
// Convert <a> back to 'link' for handler lookup
|
||||
'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'),
|
||||
_ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag),
|
||||
_ => _HtmlTag.unsupported,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that renders text with optional HTML-style formatting.
|
||||
///
|
||||
/// Supports the following tags:
|
||||
/// - `<b>` or `<strong>` for bold text
|
||||
/// - `<link>` or any tag ending with `-link` for tappable links
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ImmichHtmlText(
|
||||
/// 'Refer to <link>docs</link> and <other-link>other</other-link>',
|
||||
/// linkHandlers: {
|
||||
/// 'link': () => launchUrl(docsUrl),
|
||||
/// 'other-link': () => launchUrl(otherUrl),
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class ImmichHtmlText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final bool? softWrap;
|
||||
final Map<String, VoidCallback>? linkHandlers;
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
const ImmichHtmlText(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.softWrap,
|
||||
this.linkHandlers,
|
||||
this.linkStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichHtmlText> createState() => _ImmichHtmlTextState();
|
||||
}
|
||||
|
||||
class _ImmichHtmlTextState extends State<ImmichHtmlText> {
|
||||
final _recognizers = <GestureRecognizer>[];
|
||||
dom.DocumentFragment _document = dom.DocumentFragment();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ImmichHtmlText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.text != widget.text) {
|
||||
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
|
||||
}
|
||||
}
|
||||
|
||||
/// `<link>` tags are preprocessed to `<a>` tags because `<link>` is a
|
||||
/// void element in HTML5 and cannot have children. The linkHandlers still use
|
||||
/// 'link' as the key.
|
||||
String _preprocessHtml(String html) {
|
||||
return html
|
||||
.replaceAllMapped(
|
||||
RegExp(r'<(link)>(.*?)</\1>', caseSensitive: false),
|
||||
(match) => '<a>${match.group(2)}</a>',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'<(link)\s*/>', caseSensitive: false),
|
||||
(match) => '<a></a>',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildSpans() {
|
||||
_disposeRecognizers();
|
||||
|
||||
return _document.nodes.expand((node) => _buildNode(node, null, null)).toList();
|
||||
}
|
||||
|
||||
Iterable<InlineSpan> _buildNode(
|
||||
dom.Node node,
|
||||
TextStyle? style,
|
||||
_HtmlTag? parentTag,
|
||||
) sync* {
|
||||
if (node is dom.Text) {
|
||||
if (node.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (parentTag?.type == _HtmlTagType.link) {
|
||||
final handler = widget.linkHandlers?[parentTag?.tagName];
|
||||
if (handler != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = handler;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
}
|
||||
|
||||
yield TextSpan(text: node.text, style: style, recognizer: recognizer);
|
||||
} else if (node is dom.Element) {
|
||||
final htmlTag = _HtmlTag.fromString(node);
|
||||
final tagStyle = _styleForTag(htmlTag);
|
||||
final mergedStyle = style?.merge(tagStyle) ?? tagStyle;
|
||||
final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag;
|
||||
|
||||
for (final child in node.nodes) {
|
||||
yield* _buildNode(child, mergedStyle, newParentTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle? _styleForTag(_HtmlTag? tag) {
|
||||
if (tag == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (tag.type) {
|
||||
_HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold),
|
||||
_HtmlTagType.link => widget.linkStyle ??
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
_HtmlTagType.unsupported => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildSpans()),
|
||||
textAlign: widget.textAlign,
|
||||
overflow: widget.overflow,
|
||||
maxLines: widget.maxLines,
|
||||
softWrap: widget.softWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,14 +41,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -67,14 +59,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -7,7 +7,6 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
html: ^0.15.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextBoldText extends StatelessWidget {
|
||||
const FormattedTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText('This is <b>bold text</b>.');
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -1,25 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextLinks extends StatelessWidget {
|
||||
const HtmlTextLinks({super.key});
|
||||
class FormattedTextLinks extends StatelessWidget {
|
||||
const FormattedTextLinks({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
return ImmichFormattedText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
linkHandlers: {
|
||||
'docs-link': () {
|
||||
ScaffoldMessenger.of(
|
||||
spanBuilder: (tag) => FormattedSpan(
|
||||
onTap: switch (tag) {
|
||||
'docs-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Docs link clicked!')));
|
||||
},
|
||||
'github-link': () {
|
||||
ScaffoldMessenger.of(
|
||||
).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
|
||||
'github-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!')));
|
||||
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
|
||||
_ => null,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextMixedContent extends StatelessWidget {
|
||||
const FormattedTextMixedContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'You can use <b>bold text</b> and <link>links</link> together.',
|
||||
spanBuilder: (tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_ => FormattedSpan(
|
||||
onTap: () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextBoldText extends StatelessWidget {
|
||||
const HtmlTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'This is <b>bold text</b> and <strong>strong text</strong>.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextNestedTags extends StatelessWidget {
|
||||
const HtmlTextNestedTags({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'You can <b>combine <link>bold and links</link></b> together.',
|
||||
linkHandlers: {
|
||||
'link': () {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Nested link clicked!')));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_mixed_tags.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class FormattedTextPage extends StatelessWidget {
|
||||
const FormattedTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.formattedText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichFormattedText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const FormattedTextBoldText(),
|
||||
code: 'formatted_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const FormattedTextLinks(),
|
||||
code: 'formatted_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Mixed Content',
|
||||
preview: const FormattedTextMixedContent(),
|
||||
code: 'formatted_text_mixed_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_nested_tags.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class HtmlTextPage extends StatelessWidget {
|
||||
const HtmlTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.htmlText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichHtmlText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const HtmlTextBoldText(),
|
||||
code: 'html_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const HtmlTextLinks(),
|
||||
code: 'html_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Nested Tags',
|
||||
preview: const HtmlTextNestedTags(),
|
||||
code: 'html_text_nested_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/pages/components/close_button_page.dart';
|
||||
import 'package:showcase/pages/components/form_page.dart';
|
||||
import 'package:showcase/pages/components/html_text_page.dart';
|
||||
import 'package:showcase/pages/components/formatted_text_page.dart';
|
||||
import 'package:showcase/pages/components/icon_button_page.dart';
|
||||
import 'package:showcase/pages/components/password_input_page.dart';
|
||||
import 'package:showcase/pages/components/text_button_page.dart';
|
||||
@@ -34,7 +34,7 @@ class AppRouter {
|
||||
AppRoute.textInput => const TextInputPage(),
|
||||
AppRoute.passwordInput => const PasswordInputPage(),
|
||||
AppRoute.form => const FormPage(),
|
||||
AppRoute.htmlText => const HtmlTextPage(),
|
||||
AppRoute.formattedText => const FormattedTextPage(),
|
||||
AppRoute.constants => const ConstantsPage(),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -60,10 +60,10 @@ enum AppRoute {
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.description_outlined,
|
||||
),
|
||||
htmlText(
|
||||
name: 'Html Text',
|
||||
formattedText(
|
||||
name: 'Formatted Text',
|
||||
description: 'Render text with HTML formatting',
|
||||
path: '/html-text',
|
||||
path: '/formatted-text',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.code_rounded,
|
||||
),
|
||||
|
||||
@@ -49,14 +49,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -136,14 +128,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
immich_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -227,10 +211,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,10 +312,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.6"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+29
-84
@@ -1,21 +1,16 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/components/html_text.dart';
|
||||
import 'package:immich_ui/src/components/formatted_text.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
/// Text.rich creates a nested structure: root -> wrapper -> actual children
|
||||
/// Text.rich creates a nested structure: root (DefaultTextStyle) -> wrapper (ImmichFormattedText) -> actual children
|
||||
List<InlineSpan> _getContentSpans(WidgetTester tester) {
|
||||
final richText = tester.widget<RichText>(find.byType(RichText));
|
||||
final root = richText.text as TextSpan;
|
||||
|
||||
if (root.children?.isNotEmpty ?? false) {
|
||||
final wrapper = root.children!.first;
|
||||
if (wrapper is TextSpan && wrapper.children != null) {
|
||||
return wrapper.children!;
|
||||
}
|
||||
}
|
||||
final wrapper = root.children?.firstOrNull;
|
||||
if (wrapper is TextSpan) return wrapper.children ?? [];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -38,42 +33,18 @@ void _triggerTap(TextSpan span) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ImmichHtmlText', () {
|
||||
group('ImmichFormattedText', () {
|
||||
testWidgets('renders plain text without HTML tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is plain text'),
|
||||
const ImmichFormattedText('This is plain text'),
|
||||
);
|
||||
|
||||
expect(find.text('This is plain text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles mixed content with bold and links', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'This is an <b>example</b> of <b><link>HTML text</link></b> with <b>bold</b>.',
|
||||
linkHandlers: {'link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
|
||||
final exampleSpan = _findSpan(spans, 'example');
|
||||
expect(exampleSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final boldSpan = _findSpan(spans, 'bold');
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final linkSpan = _findSpan(spans, 'HTML text');
|
||||
expect(linkSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(linkSpan.style?.fontWeight, FontWeight.bold);
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
|
||||
expect(_concatenateText(spans), 'This is an example of HTML text with bold.');
|
||||
});
|
||||
|
||||
testWidgets('applies text style properties', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText(
|
||||
const ImmichFormattedText(
|
||||
'Test text',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
@@ -97,7 +68,7 @@ void main() {
|
||||
|
||||
testWidgets('handles text with special characters', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('Text with & < > " \' characters'),
|
||||
const ImmichFormattedText('Text with & < > " \' characters'),
|
||||
);
|
||||
|
||||
expect(find.byType(RichText), findsOneWidget);
|
||||
@@ -109,7 +80,7 @@ void main() {
|
||||
group('bold', () {
|
||||
testWidgets('renders bold text with <b> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <b>bold</b> text'),
|
||||
const ImmichFormattedText('This is <b>bold</b> text'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
@@ -118,41 +89,14 @@ void main() {
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
expect(_concatenateText(spans), 'This is bold text');
|
||||
});
|
||||
|
||||
testWidgets('renders bold text with <strong> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <strong>strong</strong> text'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final strongSpan = _findSpan(spans, 'strong');
|
||||
|
||||
expect(strongSpan.style?.fontWeight, FontWeight.bold);
|
||||
});
|
||||
|
||||
testWidgets('handles nested bold tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('Text with <b>bold and <strong>nested</strong></b>'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
|
||||
final nestedSpan = _findSpan(spans, 'nested');
|
||||
expect(nestedSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final boldSpan = _findSpan(spans, 'bold and ');
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
expect(_concatenateText(spans), 'Text with bold and nested');
|
||||
});
|
||||
});
|
||||
|
||||
group('link', () {
|
||||
testWidgets('renders link text with <link> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'This is a <link>custom link</link> text',
|
||||
linkHandlers: {'link': () {}},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () {}, _ => null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -167,9 +111,9 @@ void main() {
|
||||
var linkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Tap <link>here</link>',
|
||||
linkHandlers: {'link': () => linkTapped = true},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () => linkTapped = true, _ => null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -183,12 +127,13 @@ void main() {
|
||||
|
||||
testWidgets('handles custom prefixed link tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
|
||||
linkHandlers: {
|
||||
'docs-link': () {},
|
||||
'other-link': () {},
|
||||
},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) {
|
||||
'docs-link' => () {},
|
||||
'other-link' => () {},
|
||||
_ => null,
|
||||
},),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -207,10 +152,9 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Click <link>here</link>',
|
||||
linkStyle: customLinkStyle,
|
||||
linkHandlers: {'link': () {}},
|
||||
spanBuilder: (tag) => FormattedSpan(style: customLinkStyle, onTap: () {}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -223,9 +167,9 @@ void main() {
|
||||
|
||||
testWidgets('link without handler renders but is not tappable', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Link without handler: <link>click me</link>',
|
||||
linkHandlers: {'other-link': () {}},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'other-link' => () {}, _ => null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -241,12 +185,13 @@ void main() {
|
||||
var secondLinkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Go to <docs-link>docs</docs-link> or <help-link>help</help-link>',
|
||||
linkHandlers: {
|
||||
'docs-link': () => firstLinkTapped = true,
|
||||
'help-link': () => secondLinkTapped = true,
|
||||
},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) {
|
||||
'docs-link' => () => firstLinkTapped = true,
|
||||
'help-link' => () => secondLinkTapped = true,
|
||||
_ => null,
|
||||
},),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
@@ -13,38 +14,6 @@ void main() {
|
||||
late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
|
||||
late DriftAlbumApiRepository mockAlbumApiRepo;
|
||||
|
||||
setUp(() {
|
||||
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
|
||||
mockAlbumApiRepo = MockDriftAlbumApiRepository();
|
||||
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
|
||||
|
||||
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
|
||||
// Simulate a timestamp for the newest asset in the album
|
||||
final albumID = invocation.positionalArguments[0] as String;
|
||||
|
||||
if (albumID == '1') {
|
||||
return Future.value(DateTime(2023, 1, 1));
|
||||
} else if (albumID == '2') {
|
||||
return Future.value(DateTime(2023, 2, 1));
|
||||
}
|
||||
|
||||
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
|
||||
});
|
||||
|
||||
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
|
||||
// Simulate a timestamp for the oldest asset in the album
|
||||
final albumID = invocation.positionalArguments[0] as String;
|
||||
|
||||
if (albumID == '1') {
|
||||
return Future.value(DateTime(2019, 1, 1));
|
||||
} else if (albumID == '2') {
|
||||
return Future.value(DateTime(2019, 2, 1));
|
||||
}
|
||||
|
||||
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
|
||||
});
|
||||
});
|
||||
|
||||
final albumA = RemoteAlbum(
|
||||
id: '1',
|
||||
name: 'Album A',
|
||||
@@ -73,6 +42,21 @@ void main() {
|
||||
isShared: false,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
|
||||
mockAlbumApiRepo = MockDriftAlbumApiRepository();
|
||||
|
||||
when(
|
||||
() => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.end),
|
||||
).thenAnswer((_) async => ['1', '2']);
|
||||
|
||||
when(
|
||||
() => mockRemoteAlbumRepo.getSortedAlbumIds(any(), aggregation: AssetDateAggregation.start),
|
||||
).thenAnswer((_) async => ['1', '2']);
|
||||
|
||||
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
|
||||
});
|
||||
|
||||
group('sortAlbums', () {
|
||||
test('should sort correctly based on name', () async {
|
||||
final albums = [albumB, albumA];
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -66,6 +67,7 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
@@ -94,11 +96,19 @@ void main() {
|
||||
|
||||
when(() => mockAbortCallbackWrapper()).thenReturn(false);
|
||||
|
||||
when(() => mockSyncApiRepo.streamChanges(any())).thenAnswer((invocation) async {
|
||||
when(() => mockSyncApiRepo.streamChanges(any(), serverVersion: any(named: 'serverVersion'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
handleEventsCallback = invocation.positionalArguments.first;
|
||||
});
|
||||
|
||||
when(() => mockSyncApiRepo.streamChanges(any(), onReset: any(named: 'onReset'))).thenAnswer((invocation) async {
|
||||
when(
|
||||
() => mockSyncApiRepo.streamChanges(
|
||||
any(),
|
||||
onReset: any(named: 'onReset'),
|
||||
serverVersion: any(named: 'serverVersion'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
handleEventsCallback = invocation.positionalArguments.first;
|
||||
});
|
||||
|
||||
@@ -106,9 +116,9 @@ void main() {
|
||||
when(() => mockSyncApiRepo.deleteSyncAck(any())).thenAnswer((_) async => {});
|
||||
|
||||
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
|
||||
when(() => mockServerApi.getServerVersion()).thenAnswer(
|
||||
(_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0),
|
||||
);
|
||||
when(
|
||||
() => mockServerApi.getServerVersion(),
|
||||
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
|
||||
|
||||
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
|
||||
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ import 'schema_v16.dart' as v16;
|
||||
import 'schema_v17.dart' as v17;
|
||||
import 'schema_v18.dart' as v18;
|
||||
import 'schema_v19.dart' as v19;
|
||||
import 'schema_v20.dart' as v20;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -65,6 +66,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v18.DatabaseAtV18(db);
|
||||
case 19:
|
||||
return v19.DatabaseAtV19(db);
|
||||
case 20:
|
||||
return v20.DatabaseAtV20(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -90,5 +93,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
];
|
||||
}
|
||||
|
||||
+8471
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
import '../../medium/repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftBackupRepository sut;
|
||||
|
||||
setUp(() {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftBackupRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getAllCounts', () {
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
test('returns zeros when no albums exist', () async {
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('returns zeros when no selected albums exist', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('counts asset in selected album as total and remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 1);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('backed up asset reduces remainder', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 0);
|
||||
expect(result.processing, 0);
|
||||
});
|
||||
|
||||
test('asset with null checksum is counted as processing', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 1);
|
||||
expect(result.processing, 1);
|
||||
});
|
||||
|
||||
test('asset in excluded album is not counted even if also in selected album', () async {
|
||||
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 0);
|
||||
expect(result.remainder, 0);
|
||||
});
|
||||
|
||||
test('counts assets across multiple selected albums without duplicates', () async {
|
||||
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
// Same asset in two selected albums
|
||||
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
});
|
||||
|
||||
test('backed up asset for different user is still counted as remainder', () async {
|
||||
final otherUser = await ctx.newUser();
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 1);
|
||||
expect(result.remainder, 1);
|
||||
});
|
||||
|
||||
test('mixed assets produce correct combined counts', () async {
|
||||
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
|
||||
// backed up
|
||||
final remote1 = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local1 = await ctx.newLocalAsset(checksum: remote1.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local1.id);
|
||||
|
||||
// not backed up, has checksum
|
||||
final local2 = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local2.id);
|
||||
|
||||
// processing (null checksum)
|
||||
final local3 = await ctx.newLocalAsset(checksumOption: const Option.none());
|
||||
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local3.id);
|
||||
|
||||
final result = await sut.getAllCounts(userId);
|
||||
expect(result.total, 3);
|
||||
expect(result.remainder, 2); // local2 + local3
|
||||
expect(result.processing, 1); // local3
|
||||
});
|
||||
});
|
||||
|
||||
group('getCandidates', () {
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
test('returns empty list when no selected albums exist', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('returns asset in selected album that is not backed up', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
|
||||
test('excludes asset already backed up for the same user', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final remote = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('includes asset backed up for a different user', () async {
|
||||
final otherUser = await ctx.newUser();
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
|
||||
final local = await ctx.newLocalAsset(checksum: remote.checksum);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, local.id);
|
||||
});
|
||||
|
||||
test('excludes asset in excluded album even if also in selected album', () async {
|
||||
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('excludes asset with null checksum when onlyHashed is true', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('includes asset with null checksum when onlyHashed is false', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId, onlyHashed: false);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
|
||||
test('returns assets ordered by createdAt descending', () async {
|
||||
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset1 = await ctx.newLocalAsset(createdAt: DateTime(2024, 1, 1));
|
||||
final asset2 = await ctx.newLocalAsset(createdAt: DateTime(2024, 3, 1));
|
||||
final asset3 = await ctx.newLocalAsset(createdAt: DateTime(2024, 2, 1));
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset1.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset2.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset3.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]);
|
||||
});
|
||||
|
||||
test('does not return duplicate when asset is in multiple selected albums', () async {
|
||||
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
|
||||
final asset = await ctx.newLocalAsset();
|
||||
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
|
||||
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getCandidates(userId);
|
||||
expect(result.length, 1);
|
||||
expect(result.first.id, asset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
|
||||
import '../../medium/repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftRemoteAlbumRepository sut;
|
||||
|
||||
setUp(() async {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftRemoteAlbumRepository(ctx.db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getSortedAlbumIds', () {
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
test('returns empty list when albumIds is empty', () async {
|
||||
final result = await sut.getSortedAlbumIds([], aggregation: AssetDateAggregation.start);
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('returns single album when only one album exists', () async {
|
||||
final album = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album.id, assetId: asset.id);
|
||||
|
||||
final result = await sut.getSortedAlbumIds([album.id], aggregation: AssetDateAggregation.start);
|
||||
expect(result, [album.id]);
|
||||
});
|
||||
|
||||
test('sorts albums by start date (MIN) ascending', () async {
|
||||
// Album 1: Assets from Jan 10 to Jan 20 (start: Jan 10)
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
|
||||
|
||||
// Album 2: Assets from Jan 5 to Jan 15 (start: Jan 5)
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
|
||||
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id);
|
||||
|
||||
// Album 3: Assets from Jan 25 to Jan 30 (start: Jan 25)
|
||||
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
|
||||
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id);
|
||||
|
||||
final result = await sut.getSortedAlbumIds([
|
||||
album1.id,
|
||||
album2.id,
|
||||
album3.id,
|
||||
], aggregation: AssetDateAggregation.start);
|
||||
|
||||
// Expected order: album2 (Jan 5), album1 (Jan 10), album3 (Jan 25)
|
||||
expect(result, [album2.id, album1.id, album3.id]);
|
||||
});
|
||||
|
||||
test('sorts albums by end date (MAX) ascending', () async {
|
||||
// Album 1: Assets from Jan 10 to Jan 20 (end: Jan 20)
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
|
||||
|
||||
// Album 2: Assets from Jan 5 to Jan 15 (end: Jan 15)
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
|
||||
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset3.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset4.id);
|
||||
|
||||
// Album 3: Assets from Jan 25 to Jan 30 (end: Jan 30)
|
||||
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
|
||||
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 30));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset5.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset6.id);
|
||||
|
||||
final result = await sut.getSortedAlbumIds([
|
||||
album1.id,
|
||||
album2.id,
|
||||
album3.id,
|
||||
], aggregation: AssetDateAggregation.end);
|
||||
|
||||
// Expected order: album2 (Jan 15), album1 (Jan 20), album3 (Jan 30)
|
||||
expect(result, [album2.id, album1.id, album3.id]);
|
||||
});
|
||||
|
||||
test('handles albums with single asset', () async {
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
|
||||
|
||||
final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
|
||||
|
||||
expect(result, [album2.id, album1.id]);
|
||||
});
|
||||
|
||||
test('only returns requested album IDs in the result', () async {
|
||||
// Create 3 albums
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
|
||||
|
||||
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id);
|
||||
|
||||
// Only request album1 and album3
|
||||
final result = await sut.getSortedAlbumIds([album1.id, album3.id], aggregation: AssetDateAggregation.start);
|
||||
|
||||
// Should only return album1 and album3, not album2
|
||||
expect(result, [album1.id, album3.id]);
|
||||
});
|
||||
|
||||
test('handles albums with same date correctly', () async {
|
||||
final sameDate = DateTime(2024, 1, 10);
|
||||
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: sameDate);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
|
||||
|
||||
final result = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
|
||||
|
||||
// Both albums have the same date, so both should be returned
|
||||
expect(result, hasLength(2));
|
||||
expect(result, containsAll([album1.id, album2.id]));
|
||||
});
|
||||
|
||||
test('handles albums across different years', () async {
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2023, 12, 25));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset2.id);
|
||||
|
||||
final album3 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2025, 1, 1));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album3.id, assetId: asset3.id);
|
||||
|
||||
final result = await sut.getSortedAlbumIds([
|
||||
album1.id,
|
||||
album2.id,
|
||||
album3.id,
|
||||
], aggregation: AssetDateAggregation.start);
|
||||
|
||||
expect(result, [album1.id, album2.id, album3.id]);
|
||||
});
|
||||
|
||||
test('handles album with multiple assets correctly', () async {
|
||||
final album1 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
// Album 1 has 5 assets from Jan 5 to Jan 25
|
||||
final asset1 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 5));
|
||||
final asset2 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 10));
|
||||
final asset3 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 15));
|
||||
final asset4 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 20));
|
||||
final asset5 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 25));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset1.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset2.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset3.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset4.id);
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album1.id, assetId: asset5.id);
|
||||
|
||||
final album2 = await ctx.newRemoteAlbum(ownerId: userId);
|
||||
final asset6 = await ctx.newRemoteAsset(ownerId: userId, createdAt: DateTime(2024, 1, 1));
|
||||
await ctx.insertRemoteAlbumAsset(albumId: album2.id, assetId: asset6.id);
|
||||
|
||||
final resultStart = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.start);
|
||||
|
||||
// album2 (Jan 1) should come before album1 (Jan 5)
|
||||
expect(resultStart, [album2.id, album1.id]);
|
||||
|
||||
final resultEnd = await sut.getSortedAlbumIds([album1.id, album2.id], aggregation: AssetDateAggregation.end);
|
||||
|
||||
// album2 (Jan 1) should come before album1 (Jan 25)
|
||||
expect(resultEnd, [album2.id, album1.id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -72,8 +73,14 @@ void main() {
|
||||
|
||||
Future<void> streamChanges(
|
||||
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onDataCallback,
|
||||
SemVer serverVersion,
|
||||
) {
|
||||
return sut.streamChanges(onDataCallback, batchSize: testBatchSize, httpClient: mockHttpClient);
|
||||
return sut.streamChanges(
|
||||
onDataCallback,
|
||||
batchSize: testBatchSize,
|
||||
httpClient: mockHttpClient,
|
||||
serverVersion: serverVersion,
|
||||
);
|
||||
}
|
||||
|
||||
test('streamChanges stops processing stream when abort is called', () async {
|
||||
@@ -94,7 +101,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
final streamChangesFuture = streamChanges(onDataCallback);
|
||||
final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
// Give the stream subscription time to start (longer delay to account for mock delay)
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
@@ -145,7 +152,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
final streamChangesFuture = streamChanges(onDataCallback);
|
||||
final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
@@ -197,7 +204,7 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
final streamChangesFuture = streamChanges(onDataCallback);
|
||||
final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
@@ -244,7 +251,7 @@ void main() {
|
||||
onDataCallCount++;
|
||||
}
|
||||
|
||||
final streamChangesFuture = streamChanges(onDataCallback);
|
||||
final streamChangesFuture = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
@@ -271,7 +278,7 @@ void main() {
|
||||
onDataCallCount++;
|
||||
}
|
||||
|
||||
final future = streamChanges(onDataCallback);
|
||||
final future = streamChanges(onDataCallback, const SemVer(major: 2, minor: 5, patch: 0));
|
||||
|
||||
errorBodyController.add(utf8.encode('{"error":"Unauthorized"}'));
|
||||
await errorBodyController.close();
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.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/user.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_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.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/option.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class MediumRepositoryContext {
|
||||
final Drift db;
|
||||
final Random _random = Random();
|
||||
|
||||
MediumRepositoryContext() : db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
|
||||
Future<void> dispose() async {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
static Value<T> _resolveUndefined<T>(T? plain, Option<T>? option, T fallback) {
|
||||
if (plain != null) {
|
||||
return Value(plain);
|
||||
}
|
||||
|
||||
return _resolveOption(option, fallback);
|
||||
}
|
||||
|
||||
static Value<T> _resolveOption<T>(Option<T>? option, T fallback) {
|
||||
if (option != null) {
|
||||
return option.fold(Value.new, Value.absent);
|
||||
}
|
||||
|
||||
return Value(fallback);
|
||||
}
|
||||
|
||||
Future<UserEntityData> newUser({
|
||||
String? id,
|
||||
String? email,
|
||||
AvatarColor? avatarColor,
|
||||
DateTime? profileChangedAt,
|
||||
bool? hasProfileImage,
|
||||
}) async {
|
||||
id = id ?? const Uuid().v4();
|
||||
return await db
|
||||
.into(db.userEntity)
|
||||
.insertReturning(
|
||||
UserEntityCompanion(
|
||||
id: Value(id),
|
||||
email: Value(email ?? '$id@test.com'),
|
||||
name: Value(email ?? 'user_$id'),
|
||||
avatarColor: Value(avatarColor ?? AvatarColor.values[_random.nextInt(AvatarColor.values.length)]),
|
||||
profileChangedAt: Value(profileChangedAt ?? DateTime.now()),
|
||||
hasProfileImage: Value(hasProfileImage ?? false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<RemoteAssetEntityData> newRemoteAsset({
|
||||
String? id,
|
||||
String? checksum,
|
||||
String? ownerId,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? deletedAt,
|
||||
AssetType? type,
|
||||
AssetVisibility? visibility,
|
||||
int? durationInSeconds,
|
||||
int? width,
|
||||
int? height,
|
||||
bool? isFavorite,
|
||||
bool? isEdited,
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
String? thumbHash,
|
||||
String? libraryId,
|
||||
}) async {
|
||||
id = id ?? const Uuid().v4();
|
||||
createdAt = createdAt ?? DateTime.now();
|
||||
return db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insertReturning(
|
||||
RemoteAssetEntityCompanion(
|
||||
id: Value(id),
|
||||
name: Value('remote_$id.jpg'),
|
||||
checksum: Value(checksum ?? const Uuid().v4()),
|
||||
type: Value(type ?? AssetType.image),
|
||||
createdAt: Value(createdAt),
|
||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
||||
ownerId: Value(ownerId ?? const Uuid().v4()),
|
||||
visibility: Value(visibility ?? AssetVisibility.timeline),
|
||||
deletedAt: Value(deletedAt),
|
||||
durationInSeconds: Value(durationInSeconds ?? 0),
|
||||
width: Value(width ?? _random.nextInt(1000)),
|
||||
height: Value(height ?? _random.nextInt(1000)),
|
||||
isFavorite: Value(isFavorite ?? false),
|
||||
isEdited: Value(isEdited ?? false),
|
||||
livePhotoVideoId: Value(livePhotoVideoId),
|
||||
stackId: Value(stackId),
|
||||
localDateTime: Value(createdAt.toLocal()),
|
||||
thumbHash: Value(thumbHash ?? const Uuid().v4()),
|
||||
libraryId: Value(libraryId ?? const Uuid().v4()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<RemoteAssetCloudIdEntityData> newRemoteAssetCloudId({
|
||||
String? id,
|
||||
String? cloudId,
|
||||
DateTime? createdAt,
|
||||
DateTime? adjustmentTime,
|
||||
Option<DateTime>? adjustmentTimeOption,
|
||||
Option<double>? latitude,
|
||||
Option<double>? longitude,
|
||||
}) {
|
||||
return db
|
||||
.into(db.remoteAssetCloudIdEntity)
|
||||
.insertReturning(
|
||||
RemoteAssetCloudIdEntityCompanion(
|
||||
assetId: Value(id ?? const Uuid().v4()),
|
||||
cloudId: Value(cloudId ?? const Uuid().v4()),
|
||||
createdAt: Value(createdAt ?? DateTime.now()),
|
||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||
latitude: _resolveOption(latitude, _random.nextDouble() * 180 - 90),
|
||||
longitude: _resolveOption(longitude, _random.nextDouble() * 360 - 180),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<RemoteAlbumEntityData> newRemoteAlbum({
|
||||
String? id,
|
||||
String? name,
|
||||
String? ownerId,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? description,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
String? thumbnailAssetId,
|
||||
}) async {
|
||||
id = id ?? const Uuid().v4();
|
||||
return db
|
||||
.into(db.remoteAlbumEntity)
|
||||
.insertReturning(
|
||||
RemoteAlbumEntityCompanion(
|
||||
id: Value(id),
|
||||
name: Value(name ?? 'remote_album_$id'),
|
||||
ownerId: Value(ownerId ?? const Uuid().v4()),
|
||||
createdAt: Value(createdAt ?? DateTime.now()),
|
||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
||||
description: Value(description ?? 'Description for album $id'),
|
||||
isActivityEnabled: Value(isActivityEnabled ?? false),
|
||||
order: Value(order ?? AlbumAssetOrder.asc),
|
||||
thumbnailAssetId: Value(thumbnailAssetId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAlbumAsset({required String albumId, required String assetId}) {
|
||||
return db
|
||||
.into(db.remoteAlbumAssetEntity)
|
||||
.insert(RemoteAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
|
||||
}
|
||||
|
||||
Future<LocalAssetEntityData> newLocalAsset({
|
||||
String? id,
|
||||
String? name,
|
||||
String? checksum,
|
||||
Option<String>? checksumOption,
|
||||
DateTime? createdAt,
|
||||
AssetType? type,
|
||||
bool? isFavorite,
|
||||
String? iCloudId,
|
||||
DateTime? adjustmentTime,
|
||||
Option<DateTime>? adjustmentTimeOption,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationInSeconds,
|
||||
int? orientation,
|
||||
DateTime? updatedAt,
|
||||
}) async {
|
||||
id = id ?? const Uuid().v4();
|
||||
return db
|
||||
.into(db.localAssetEntity)
|
||||
.insertReturning(
|
||||
LocalAssetEntityCompanion(
|
||||
id: Value(id),
|
||||
name: Value(name ?? 'local_$id.jpg'),
|
||||
height: Value(height ?? _random.nextInt(1000)),
|
||||
width: Value(width ?? _random.nextInt(1000)),
|
||||
durationInSeconds: Value(durationInSeconds ?? 0),
|
||||
orientation: Value(orientation ?? 0),
|
||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
||||
checksum: _resolveUndefined(checksum, checksumOption, const Uuid().v4()),
|
||||
createdAt: Value(createdAt ?? DateTime.now()),
|
||||
type: Value(type ?? AssetType.image),
|
||||
isFavorite: Value(isFavorite ?? false),
|
||||
iCloudId: Value(iCloudId ?? const Uuid().v4()),
|
||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||
latitude: Value(latitude ?? _random.nextDouble() * 180 - 90),
|
||||
longitude: Value(longitude ?? _random.nextDouble() * 360 - 180),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<LocalAlbumEntityData> newLocalAlbum({
|
||||
String? id,
|
||||
String? name,
|
||||
DateTime? updatedAt,
|
||||
BackupSelection? backupSelection,
|
||||
bool? isIosSharedAlbum,
|
||||
String? linkedRemoteAlbumId,
|
||||
}) {
|
||||
id = id ?? const Uuid().v4();
|
||||
return db
|
||||
.into(db.localAlbumEntity)
|
||||
.insertReturning(
|
||||
LocalAlbumEntityCompanion(
|
||||
id: Value(id),
|
||||
name: Value(name ?? 'local_album_$id'),
|
||||
updatedAt: Value(updatedAt ?? DateTime.now()),
|
||||
backupSelection: Value(backupSelection ?? BackupSelection.none),
|
||||
isIosSharedAlbum: Value(isIosSharedAlbum ?? false),
|
||||
linkedRemoteAlbumId: Value(linkedRemoteAlbumId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> newLocalAlbumAsset({required String albumId, required String assetId}) {
|
||||
return db
|
||||
.into(db.localAlbumAssetEntity)
|
||||
.insert(LocalAlbumAssetEntityCompanion.insert(albumId: albumId, assetId: assetId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
void main() {
|
||||
group('Option', () {
|
||||
group('constructors', () {
|
||||
test('Option.some creates a Some instance', () {
|
||||
const option = Option.some(42);
|
||||
expect(option, isA<Some<int>>());
|
||||
expect((option as Some).value, 42);
|
||||
});
|
||||
|
||||
test('Option.none creates a None instance', () {
|
||||
const option = Option<int>.none();
|
||||
expect(option, isA<None<int>>());
|
||||
});
|
||||
|
||||
test('Option.fromNullable returns Some for non-null value', () {
|
||||
final option = Option.fromNullable('hello');
|
||||
expect(option, isA<Some<String>>());
|
||||
expect((option as Some).value, 'hello');
|
||||
});
|
||||
|
||||
test('Option.fromNullable returns None for null value', () {
|
||||
final option = Option.fromNullable(null);
|
||||
expect(option, isA<None>());
|
||||
});
|
||||
});
|
||||
|
||||
group('isSome / isNone', () {
|
||||
test('Some.isSome is true', () {
|
||||
expect(const Option.some(1).isSome, isTrue);
|
||||
});
|
||||
|
||||
test('Some.isNone is false', () {
|
||||
expect(const Option.some(1).isNone, isFalse);
|
||||
});
|
||||
|
||||
test('None.isSome is false', () {
|
||||
expect(const Option.none().isSome, isFalse);
|
||||
});
|
||||
|
||||
test('None.isNone is true', () {
|
||||
expect(const Option.none().isNone, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('unwrapOrNull', () {
|
||||
test('returns value for Some', () {
|
||||
expect(const Option.some('hi').unwrapOrNull, 'hi');
|
||||
});
|
||||
|
||||
test('returns null for None', () {
|
||||
expect(const Option.none().unwrapOrNull, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('fold', () {
|
||||
test('calls onSome with value for Some', () {
|
||||
final result = const Option.some('world').fold((v) => 'some: $v', () => 'none');
|
||||
expect(result, 'some: world');
|
||||
});
|
||||
|
||||
test('calls onNone for None', () {
|
||||
final result = const Option.none().fold((v) => 'some: $v', () => 'none');
|
||||
expect(result, 'none');
|
||||
});
|
||||
});
|
||||
|
||||
group('equality', () {
|
||||
test('Some equals Some with same value', () {
|
||||
expect(const Option.some(1) == const Option.some(1), isTrue);
|
||||
});
|
||||
|
||||
test('Some does not equal Some with different value', () {
|
||||
expect(const Option.some(1) == const Option.some(2), isFalse);
|
||||
});
|
||||
|
||||
test('None equals None of same type', () {
|
||||
expect(const Option<int>.none() == const Option<int>.none(), isTrue);
|
||||
});
|
||||
|
||||
test('None does not equal None of different type', () {
|
||||
expect(const Option<int>.none() == (const Option<String>.none() as Object), isFalse);
|
||||
});
|
||||
|
||||
test('Some does not equal None', () {
|
||||
expect(const Option.some(0) == const Option.none(), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('hashCode', () {
|
||||
test('Some hashCode equals value hashCode', () {
|
||||
expect(const Option.some('abc').hashCode, 'abc'.hashCode);
|
||||
});
|
||||
|
||||
test('None hashCode is 0', () {
|
||||
expect(const Option.none().hashCode, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ObjectOptionExtension', () {
|
||||
test('non-null value.toOption() returns Some', () {
|
||||
final option = 'hello'.toOption();
|
||||
expect(option, isA<Some<String>>());
|
||||
expect((option as Some).value, 'hello');
|
||||
});
|
||||
|
||||
test('null value.toOption() returns None', () {
|
||||
const String? value = null;
|
||||
final option = value.toOption();
|
||||
expect(option, isA<None<String>>());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user