feat: merge people on mobile

This commit is contained in:
Yaros
2026-07-24 14:48:50 +02:00
parent 5fa920a5f0
commit 471089ddfd
8 changed files with 324 additions and 4 deletions
@@ -31,4 +31,25 @@ class DriftPeopleService {
await _personApiRepository.update(personId, birthday: birthday);
return _repository.updateBirthday(personId, birthday);
}
Future<({int merged, int failed})> mergePerson(String targetPersonId, List<String> personIdsToMerge) async {
if (personIdsToMerge.isEmpty) {
return (merged: 0, failed: 0);
}
final mergedIds = await _personApiRepository.mergePerson(targetPersonId, personIdsToMerge);
final failed = personIdsToMerge.length - mergedIds.length;
if (mergedIds.isNotEmpty) {
final updatedTarget = await _personApiRepository.getById(targetPersonId);
await _repository.mergePeople(
targetPersonId,
mergedIds,
name: updatedTarget.name,
birthDate: updatedTarget.birthDate,
);
}
return (merged: mergedIds.length, failed: failed);
}
}
@@ -1,6 +1,7 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@@ -73,6 +74,29 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
return query.write(PersonEntityCompanion(birthDate: Value(birthday), updatedAt: Value(DateTime.now())));
}
Future<void> mergePeople(
String targetPersonId,
List<String> mergedPersonIds, {
required String name,
DateTime? birthDate,
}) {
if (mergedPersonIds.isEmpty) {
return Future.value();
}
return _db.transaction(() async {
await (_db.update(_db.assetFaceEntity)..where((face) => face.personId.isIn(mergedPersonIds))).write(
AssetFaceEntityCompanion(personId: Value(targetPersonId)),
);
await _db.personEntity.deleteWhere((row) => row.id.isIn(mergedPersonIds));
await (_db.update(_db.personEntity)..where((row) => row.id.equals(targetPersonId))).write(
PersonEntityCompanion(name: Value(name), birthDate: Value(birthDate), updatedAt: Value(DateTime.now())),
);
});
}
}
extension on PersonEntityData {
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/people/person_option_sheet.wi
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart';
@@ -49,6 +50,16 @@ class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
}
}
Future<void> handleMerge() async {
final updatedPerson = await context.pushRoute<DriftPerson?>(DriftPersonMergeRoute(person: _person));
if (updatedPerson != null) {
setState(() {
_person = updatedPerson;
});
}
}
void showOptionSheet(BuildContext context) {
showModalBottomSheet(
context: context,
@@ -64,6 +75,10 @@ class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
await handleEditBirthday(context);
ContextHelper(context).pop();
},
onMerge: () {
ContextHelper(context).pop();
handleMerge();
},
birthdayExists: _person.birthDate != null,
);
},
@@ -0,0 +1,195 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
const _kMaxMergeSelection = 5;
@RoutePage()
class DriftPersonMergePage extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonMergePage({super.key, required this.person});
@override
ConsumerState<DriftPersonMergePage> createState() => _DriftPersonMergePageState();
}
class _DriftPersonMergePageState extends ConsumerState<DriftPersonMergePage> {
final Set<String> _selectedIds = {};
bool _isMerging = false;
void _toggleSelection(String personId) {
setState(() {
if (_selectedIds.contains(personId)) {
_selectedIds.remove(personId);
return;
}
if (_selectedIds.length >= _kMaxMergeSelection) {
ref.read(toastRepositoryProvider).info(context.t.merge_people_limit);
return;
}
_selectedIds.add(personId);
});
}
Future<void> _confirmMerge() async {
if (_selectedIds.isEmpty || _isMerging) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(context.t.merge_people, style: const TextStyle(fontWeight: FontWeight.bold)),
content: Text(context.t.merge_people_prompt),
actions: [
TextButton(
onPressed: () => ContextHelper(context).pop(false),
child: Text(
context.t.cancel,
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
),
),
TextButton(
onPressed: () => ContextHelper(context).pop(true),
child: Text(
context.t.merge_people,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
),
),
],
);
},
);
if (confirmed != true || !context.mounted) {
return;
}
setState(() => _isMerging = true);
try {
final result = await ref.read(driftPeopleServiceProvider).mergePerson(widget.person.id, _selectedIds.toList());
if (!context.mounted) {
return;
}
if (result.merged > 0) {
ref.invalidate(driftGetAllPeopleProvider);
ref.read(toastRepositoryProvider).success(context.t.merged_people_count(count: result.merged));
final updatedPerson = await ref.read(driftPeopleServiceProvider).get(widget.person.id);
if (!context.mounted) {
return;
}
ContextHelper(context).pop(updatedPerson ?? widget.person);
} else {
ref.read(toastRepositoryProvider).error(context.t.cannot_merge_people);
}
} catch (error) {
dPrint(() => 'Error merging people: $error');
if (!context.mounted) {
return;
}
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
} finally {
if (context.mounted) {
setState(() => _isMerging = false);
}
}
}
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
return Scaffold(
appBar: AppBar(
title: Text(context.t.merge_people),
actions: [
TextButton(
onPressed: _selectedIds.isEmpty || _isMerging ? null : _confirmMerge,
child: _isMerging
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(context.t.merge_people),
),
],
),
body: SafeArea(
child: people.when(
data: (allPeople) {
final candidates = allPeople.where((person) => person.id != widget.person.id).toList();
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, childAspectRatio: 0.85),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: candidates.length,
itemBuilder: (context, index) {
final person = candidates[index];
final isSelected = _selectedIds.contains(person.id);
return GestureDetector(
key: ValueKey(person.id),
onTap: () => _toggleSelection(person.id),
child: Column(
children: [
Stack(
alignment: Alignment.bottomRight,
children: [
Material(
shape: CircleBorder(
side: BorderSide(color: isSelected ? context.primaryColor : Colors.transparent, width: 3),
),
elevation: 3,
child: CircleAvatar(
maxRadius: 48,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
if (isSelected)
CircleAvatar(
radius: 12,
backgroundColor: context.primaryColor,
child: const Icon(Icons.check, size: 16, color: Colors.white),
),
],
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
person.name.isEmpty ? context.t.add_a_name : person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
),
],
),
);
},
);
},
error: (error, stack) => const Center(child: Text('error')),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
class PersonOptionSheet extends ConsumerWidget {
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false});
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.onMerge, this.birthdayExists = false});
final VoidCallback? onEditName;
final VoidCallback? onEditBirthday;
final VoidCallback? onMerge;
final bool birthdayExists;
@override
@@ -21,14 +22,19 @@ class PersonOptionSheet extends ConsumerWidget {
children: [
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_name'.t(context: context), style: textStyle),
title: Text(context.t.edit_name, style: textStyle),
onTap: onEditName,
),
ListTile(
leading: const Icon(Icons.cake),
title: Text((birthdayExists ? 'edit_birthday' : "add_birthday").t(context: context), style: textStyle),
title: Text((birthdayExists ? context.t.edit_birthday : context.t.add_birthday), style: textStyle),
onTap: onEditBirthday,
),
ListTile(
leading: const Icon(Icons.merge_rounded),
title: Text(context.t.merge_people, style: textStyle),
onTap: onMerge,
),
],
),
),
@@ -26,6 +26,16 @@ class PersonApiRepository extends ApiRepository {
return _toPerson(response);
}
Future<PersonDto> getById(String id) async {
final response = await checkNull(_api.getPerson(id));
return _toPerson(response);
}
Future<List<String>> mergePerson(String id, List<String> ids) async {
final response = await checkNull(_api.mergePerson(id, MergePersonDto(ids: ids)));
return response.where((result) => result.success).map((result) => result.id).toList();
}
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
birthDate: dto.birthDate,
id: dto.id,
+2
View File
@@ -56,6 +56,7 @@ import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
import 'package:immich_mobile/presentation/pages/drift_person_merge.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
@@ -183,6 +184,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: SyncStatusRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
AutoRoute(page: DriftPersonMergeRoute.page, guards: [_authGuard]),
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
+47
View File
@@ -885,6 +885,53 @@ class DriftPeopleCollectionRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftPersonMergePage]
class DriftPersonMergeRoute extends PageRouteInfo<DriftPersonMergeRouteArgs> {
DriftPersonMergeRoute({
Key? key,
required DriftPerson person,
List<PageRouteInfo>? children,
}) : super(
DriftPersonMergeRoute.name,
args: DriftPersonMergeRouteArgs(key: key, person: person),
initialChildren: children,
);
static const String name = 'DriftPersonMergeRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftPersonMergeRouteArgs>();
return DriftPersonMergePage(key: args.key, person: args.person);
},
);
}
class DriftPersonMergeRouteArgs {
const DriftPersonMergeRouteArgs({this.key, required this.person});
final Key? key;
final DriftPerson person;
@override
String toString() {
return 'DriftPersonMergeRouteArgs{key: $key, person: $person}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DriftPersonMergeRouteArgs) return false;
return key == other.key && person == other.person;
}
@override
int get hashCode => key.hashCode ^ person.hashCode;
}
/// generated route for
/// [DriftPersonPage]
class DriftPersonRoute extends PageRouteInfo<DriftPersonRouteArgs> {