feat(mobile): people sync

This commit is contained in:
wuzihao051119 2025-07-06 19:44:32 +08:00
parent 4b7fb162ee
commit 55e7622e32
21 changed files with 2449 additions and 31 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
enum SourceType {
// do not change this order!
machineLearning,
exif,
manual,
}
class Face {
}

View File

@ -1,7 +1,8 @@
import 'dart:convert';
class Person {
const Person({
// TODO: Remove PersonDto once Isar is removed
class PersonDto {
const PersonDto({
required this.id,
this.birthDate,
required this.isHidden,
@ -22,7 +23,7 @@ class Person {
return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)';
}
Person copyWith({
PersonDto copyWith({
String? id,
DateTime? birthDate,
bool? isHidden,
@ -30,7 +31,7 @@ class Person {
String? thumbnailPath,
DateTime? updatedAt,
}) {
return Person(
return PersonDto(
id: id ?? this.id,
birthDate: birthDate ?? this.birthDate,
isHidden: isHidden ?? this.isHidden,
@ -51,8 +52,8 @@ class Person {
};
}
factory Person.fromMap(Map<String, dynamic> map) {
return Person(
factory PersonDto.fromMap(Map<String, dynamic> map) {
return PersonDto(
id: map['id'] as String,
birthDate: map['birthDate'] != null
? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int)
@ -68,11 +69,11 @@ class Person {
String toJson() => json.encode(toMap());
factory Person.fromJson(String source) =>
Person.fromMap(json.decode(source) as Map<String, dynamic>);
factory PersonDto.fromJson(String source) =>
PersonDto.fromMap(json.decode(source) as Map<String, dynamic>);
@override
bool operator ==(covariant Person other) {
bool operator ==(covariant PersonDto other) {
if (identical(this, other)) return true;
return other.id == id &&
@ -93,3 +94,60 @@ class Person {
updatedAt.hashCode;
}
}
// Model for a person stored in the server
class Person {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
final String ownerId;
final String name;
final String? thumbnailPath;
final bool isHidden;
final DateTime? birthDate;
final String? faceAssetId;
final bool isFavorite;
final String? color;
const Person({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.ownerId,
required this.name,
this.thumbnailPath,
required this.isHidden,
this.birthDate,
this.faceAssetId,
required this.isFavorite,
this.color,
});
Person copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
String? ownerId,
String? name,
String? thumbnailPath,
bool? isHidden,
DateTime? birthDate,
String? faceAssetId,
bool? isFavorite,
String? color,
}) {
return Person(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
ownerId: ownerId ?? this.ownerId,
name: name ?? this.name,
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
isHidden: isHidden ?? this.isHidden,
birthDate: birthDate ?? this.birthDate,
faceAssetId: faceAssetId ?? this.faceAssetId,
isFavorite: isFavorite ?? this.isFavorite,
color: color ?? this.color,
);
}
}

View File

@ -173,6 +173,14 @@ class SyncStreamService {
data.cast(),
debugLabel: 'partner',
);
// case SyncEntityType.personV1:
// return _syncStreamRepository.updatePeopleV1(data.cast());
// case SyncEntityType.personDeleteV1:
// return _syncStreamRepository.deletePeopleV1(data.cast());
// case SyncEntityType.faceV1:
// return _syncStreamRepository.updateFacesV1(data.cast());
// case SyncEntityType.faceDeleteV1:
// return _syncStreamRepository.deleteFacesV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}

View File

@ -0,0 +1,40 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/face_model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class AssetFaceEntity extends Table with DriftDefaultsMixin {
const AssetFaceEntity();
TextColumn get id => text()();
TextColumn get personId =>
text().nullable().references(PersonEntity, #id, onDelete: KeyAction.setNull)();
TextColumn get assetId =>
text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
IntColumn get imageHeight => integer()();
IntColumn get imageWidth => integer()();
IntColumn get boundingBoxX1 => integer()();
IntColumn get boundingBoxY1 => integer()();
IntColumn get boundingBoxX2 => integer()();
IntColumn get boundingBoxY2 => integer()();
IntColumn get sourceType => intEnum<SourceType>()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get deletedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class PersonEntity extends Table with DriftDefaultsMixin {
const PersonEntity();
TextColumn get id => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get ownerId =>
text().references(UserEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get name => text()();
TextColumn get thumbnailPath => text().nullable()();
BoolColumn get isHidden => boolean()();
DateTimeColumn get birthDate => dateTime().nullable()();
TextColumn get faceAssetId =>
text().nullable().references(AssetFaceEntity, #id, onDelete: KeyAction.setNull)();
BoolColumn get isFavorite => boolean()();
TextColumn get color => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
@ -10,6 +11,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.dart';
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
@ -52,6 +54,8 @@ class IsarDatabaseRepository implements IDatabaseRepository {
MemoryEntity,
MemoryAssetEntity,
StackEntity,
PersonEntity,
AssetFaceEntity,
],
include: {
'package:immich_mobile/infrastructure/entities/merged_asset.drift',

View File

@ -29,9 +29,13 @@ import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.
as i13;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i14;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
as i15;
import 'package:drift/internal/modular.dart' as i16;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i16;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i17;
import 'package:drift/internal/modular.dart' as i18;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@ -61,8 +65,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i13.$MemoryAssetEntityTable memoryAssetEntity =
i13.$MemoryAssetEntityTable(this);
late final i14.$StackEntityTable stackEntity = i14.$StackEntityTable(this);
i15.MergedAssetDrift get mergedAssetDrift => i16.ReadDatabaseContainer(this)
.accessor<i15.MergedAssetDrift>(i15.MergedAssetDrift.new);
late final i15.$AssetFaceEntityTable assetFaceEntity =
i15.$AssetFaceEntityTable(this);
late final i16.$PersonEntityTable personEntity = i16.$PersonEntityTable(this);
i17.MergedAssetDrift get mergedAssetDrift => i18.ReadDatabaseContainer(this)
.accessor<i17.MergedAssetDrift>(i17.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@ -84,7 +91,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
stackEntity
stackEntity,
assetFaceEntity,
personEntity
];
@override
i0.StreamQueryUpdateRules get streamUpdateRules =>
@ -216,6 +225,27 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('person_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('asset_face_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('person_entity', kind: i0.UpdateKind.update),
],
),
],
);
@override
@ -255,4 +285,8 @@ class $DriftManager {
i13.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i14.$$StackEntityTableTableManager get stackEntity =>
i14.$$StackEntityTableTableManager(_db, _db.stackEntity);
i15.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i15.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i16.$$PersonEntityTableTableManager get personEntity =>
i16.$$PersonEntityTableTableManager(_db, _db.personEntity);
}

View File

@ -56,6 +56,8 @@ class SyncApiRepository {
SyncRequestType.memoryToAssetsV1,
SyncRequestType.stacksV1,
SyncRequestType.partnerStacksV1,
SyncRequestType.peopleV1,
SyncRequestType.facesV1,
],
).toJson(),
);
@ -160,7 +162,6 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.albumToAssetV1: SyncAlbumToAssetV1.fromJson,
SyncEntityType.albumToAssetBackfillV1: SyncAlbumToAssetV1.fromJson,
SyncEntityType.albumToAssetDeleteV1: SyncAlbumToAssetDeleteV1.fromJson,
SyncEntityType.syncAckV1: _SyncAckV1.fromJson,
SyncEntityType.memoryV1: SyncMemoryV1.fromJson,
SyncEntityType.memoryDeleteV1: SyncMemoryDeleteV1.fromJson,
SyncEntityType.memoryToAssetV1: SyncMemoryAssetV1.fromJson,
@ -170,6 +171,11 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.partnerStackV1: SyncStackV1.fromJson,
SyncEntityType.partnerStackBackfillV1: SyncStackV1.fromJson,
SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson,
SyncEntityType.personV1: SyncPersonV1.fromJson,
SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson,
SyncEntityType.faceV1: SyncFaceV1.fromJson,
SyncEntityType.faceDeleteV1: SyncFaceDeleteV1.fromJson,
SyncEntityType.syncAckV1: _SyncAckV1.fromJson,
};
class _SyncAckV1 {

View File

@ -237,7 +237,7 @@ class SearchFilter {
String? filename;
String? description;
String? language;
Set<Person> people;
Set<PersonDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@ -282,7 +282,7 @@ class SearchFilter {
String? filename,
String? description,
String? language,
Set<Person>? people,
Set<PersonDto>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,

View File

@ -147,7 +147,7 @@ class SearchPage extends HookConsumerWidget {
);
showPeoplePicker() {
handleOnSelect(Set<Person> value) {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(
people: value,
);

View File

@ -69,6 +69,8 @@ final _features = [
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
await db.assetFaceEntity.deleteAll();
},
),
_Feature(

View File

@ -9,7 +9,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'people.provider.g.dart';
@riverpod
Future<List<Person>> getAllPeople(
Future<List<PersonDto>> getAllPeople(
Ref ref,
) async {
final PersonService personService = ref.read(personServiceProvider);

View File

@ -6,11 +6,12 @@ part of 'people.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$getAllPeopleHash() => r'226947af3b09ce62224916543958dd1d5e2ba651';
String _$getAllPeopleHash() => r'2c5e6a207683f15ab209650615fdf9cb7f76c736';
/// See also [getAllPeople].
@ProviderFor(getAllPeople)
final getAllPeopleProvider = AutoDisposeFutureProvider<List<Person>>.internal(
final getAllPeopleProvider =
AutoDisposeFutureProvider<List<PersonDto>>.internal(
getAllPeople,
name: r'getAllPeopleProvider',
debugGetCreateSourceHash:
@ -21,7 +22,7 @@ final getAllPeopleProvider = AutoDisposeFutureProvider<List<Person>>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<Person>>;
typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<PersonDto>>;
String _$personAssetsHash() => r'c1d35ee0e024bd6915e21bc724be4b458a14bc24';
/// Copied from Dart SDK

View File

@ -43,6 +43,8 @@ class AuthRepository extends DatabaseRepository {
_drift.memoryEntity.deleteAll(),
_drift.memoryAssetEntity.deleteAll(),
_drift.stackEntity.deleteAll(),
_drift.personEntity.deleteAll(),
_drift.assetFaceEntity.deleteAll(),
]);
});
}

View File

@ -13,19 +13,19 @@ class PersonApiRepository extends ApiRepository {
PersonApiRepository(this._api);
Future<List<Person>> getAll() async {
Future<List<PersonDto>> getAll() async {
final dto = await checkNull(_api.getAllPeople());
return dto.people.map(_toPerson).toList();
}
Future<Person> update(String id, {String? name}) async {
Future<PersonDto> update(String id, {String? name}) async {
final dto = await checkNull(
_api.updatePerson(id, PersonUpdateDto(name: name)),
);
return _toPerson(dto);
}
static Person _toPerson(PersonResponseDto dto) => Person(
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
birthDate: dto.birthDate,
id: dto.id,
isHidden: dto.isHidden,

View File

@ -28,7 +28,7 @@ class PersonService {
this._assetRepository,
);
Future<List<Person>> getAllPeople() async {
Future<List<PersonDto>> getAllPeople() async {
try {
return await _personApiRepository.getAll();
} catch (error, stack) {
@ -48,7 +48,7 @@ class PersonService {
return [];
}
Future<Person?> updateName(String id, String name) async {
Future<PersonDto?> updateName(String id, String name) async {
try {
return await _personApiRepository.update(id, name: name);
} catch (error, stack) {

View File

@ -14,8 +14,8 @@ import 'package:immich_mobile/widgets/common/search_field.dart';
class PeoplePicker extends HookConsumerWidget {
const PeoplePicker({super.key, required this.onSelect, this.filter});
final Function(Set<Person>) onSelect;
final Set<Person>? filter;
final Function(Set<PersonDto>) onSelect;
final Set<PersonDto>? filter;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget {
final searchQuery = useState('');
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
final selectedPeople = useState<Set<Person>>(filter ?? {});
final selectedPeople = useState<Set<PersonDto>>(filter ?? {});
return Column(
children: [

View File

@ -101,6 +101,14 @@ void main() {
debugLabel: any(named: 'debugLabel'),
),
).thenAnswer(successHandler);
// when(() => mockSyncStreamRepo.updatePeopleV1(any()))
// .thenAnswer(successHandler);
// when(() => mockSyncStreamRepo.deletePeopleV1(any()))
// .thenAnswer(successHandler);
// when(() => mockSyncStreamRepo.updateFacesV1(any()))
// .thenAnswer(successHandler);
// when(() => mockSyncStreamRepo.deleteFacesV1(any()))
// .thenAnswer(successHandler);
sut = SyncStreamService(
syncApiRepository: mockSyncApiRepo,