diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index 73972dbdc0..aebaf3bb5e 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -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 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); + } } diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 0dd73cc81d..6d54356921 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -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 mergePeople( + String targetPersonId, + List 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 { diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart index 3430dd1abd..2519967f84 100644 --- a/mobile/lib/presentation/pages/drift_person.page.dart +++ b/mobile/lib/presentation/pages/drift_person.page.dart @@ -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 { } } + Future handleMerge() async { + final updatedPerson = await context.pushRoute(DriftPersonMergeRoute(person: _person)); + + if (updatedPerson != null) { + setState(() { + _person = updatedPerson; + }); + } + } + void showOptionSheet(BuildContext context) { showModalBottomSheet( context: context, @@ -64,6 +75,10 @@ class _DriftPersonPageState extends ConsumerState { await handleEditBirthday(context); ContextHelper(context).pop(); }, + onMerge: () { + ContextHelper(context).pop(); + handleMerge(); + }, birthdayExists: _person.birthDate != null, ); }, diff --git a/mobile/lib/presentation/pages/drift_person_merge.page.dart b/mobile/lib/presentation/pages/drift_person_merge.page.dart new file mode 100644 index 0000000000..90d8e055ef --- /dev/null +++ b/mobile/lib/presentation/pages/drift_person_merge.page.dart @@ -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 createState() => _DriftPersonMergePageState(); +} + +class _DriftPersonMergePageState extends ConsumerState { + final Set _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 _confirmMerge() async { + if (_selectedIds.isEmpty || _isMerging) { + return; + } + + final confirmed = await showDialog( + 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()), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart index b374d48417..4dc4ea68ee 100644 --- a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart @@ -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, + ), ], ), ), diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 26662601b7..01f592e422 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -26,6 +26,16 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } + Future getById(String id) async { + final response = await checkNull(_api.getPerson(id)); + return _toPerson(response); + } + + Future> mergePerson(String id, List 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, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 34d29be945..fcaf33f41f 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 1876a784dc..d0f172a5a6 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -885,6 +885,53 @@ class DriftPeopleCollectionRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftPersonMergePage] +class DriftPersonMergeRoute extends PageRouteInfo { + DriftPersonMergeRoute({ + Key? key, + required DriftPerson person, + List? 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(); + 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 {