From 59bda709b877b66e65f28892219322ad7d1c48bd Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sat, 23 May 2026 15:08:28 +0200 Subject: [PATCH 1/6] Merge branch 'face-merge-mobile' into own-facemerge --- .../lib/domain/services/people.service.dart | 9 ++ .../person_merge_tracker.service.dart | 24 ++++ .../repositories/people.repository.dart | 21 +++ .../pages/drift_people_collection.page.dart | 2 +- .../presentation/pages/drift_person.page.dart | 131 ++++++++++++----- .../asset_details/people_details.widget.dart | 40 +++--- .../person_edit_birthday_modal.widget.dart | 4 +- .../people/person_edit_name_modal.widget.dart | 132 +++++++++++++++-- .../people/person_merge_modal.widget.dart | 136 ++++++++++++++++++ .../widgets/people/person_tile.widget.dart | 58 ++++++++ .../infrastructure/people.provider.dart | 7 + .../repositories/person_api.repository.dart | 4 + mobile/lib/routing/router.gr.dart | 18 +-- mobile/lib/services/deep_link.service.dart | 2 +- mobile/lib/utils/people.utils.dart | 15 +- .../search/search_filter/people_picker.dart | 57 ++------ 16 files changed, 541 insertions(+), 119 deletions(-) create mode 100644 mobile/lib/domain/services/person_merge_tracker.service.dart create mode 100644 mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart create mode 100644 mobile/lib/presentation/widgets/people/person_tile.widget.dart diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index ecfe83e5cb..0361848eb2 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -18,6 +18,10 @@ class DriftPeopleService { return _repository.getAssetPeople(assetId); } + Stream watchPersonById(String personId) { + return _repository.watchPersonById(personId); + } + Future> getAllPeople() { return _repository.getAllPeople(); } @@ -27,6 +31,11 @@ class DriftPeopleService { return _repository.updateName(personId, name); } + Future mergePeople({required String targetPersonId, required List mergePersonIds}) async { + await _personApiRepository.merge(targetPersonId, mergePersonIds); + return _repository.mergePeople(targetPersonId, mergePersonIds); + } + Future updateBrithday(String personId, DateTime birthday) async { await _personApiRepository.update(personId, birthday: birthday); return _repository.updateBirthday(personId, birthday); diff --git a/mobile/lib/domain/services/person_merge_tracker.service.dart b/mobile/lib/domain/services/person_merge_tracker.service.dart new file mode 100644 index 0000000000..6347e14f37 --- /dev/null +++ b/mobile/lib/domain/services/person_merge_tracker.service.dart @@ -0,0 +1,24 @@ +/// Why do we need this? +/// Say we open the profile page (drift_person.page.dart) for Person A, and then nested above +/// a image viewer for an image that belongs to Person A. +/// +/// When the users now merges user A into user B, we can't just listen to +/// the changes in the profile page, we have to keep track of where the user A (now B) +/// can be found in the DB. +/// +/// So when popping back to the profile page (and the user is missing), we check +/// which other person B we have to display instead. +class PersonMergeTrackerService { + /// Map of merged person ID -> target person ID + final Map _mergeForwardingMap = {}; + + /// Record a person merge operation + void recordMerge({required String mergedPersonId, required String targetPersonId}) { + _mergeForwardingMap[mergedPersonId] = targetPersonId; + } + + /// Get the target person ID for a merged person + String? getTargetPersonId(String personId) { + return _mergeForwardingMap[personId]; + } +} diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 9e55d44867..89b23fd640 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'; @@ -61,6 +62,12 @@ class DriftPeopleRepository extends DriftDatabaseRepository { }).get(); } + Stream watchPersonById(String personId) { + return (_db.select( + _db.personEntity, + )..where((tbl) => tbl.id.equals(personId))).watchSingleOrNull().map((entity) => entity?.toDto()); + } + Future updateName(String personId, String name) { final query = _db.update(_db.personEntity)..where((row) => row.id.equals(personId)); @@ -72,6 +79,20 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.write(PersonEntityCompanion(birthDate: Value(birthday), updatedAt: Value(DateTime.now()))); } + + Future mergePeople(String targetPersonId, List mergePersonIds) async { + return _db.transaction(() async { + // Update AssetFaceEntity to point to the target person + final updateQuery = _db.update(_db.assetFaceEntity)..where((row) => row.personId.isIn(mergePersonIds)); + final updatedCount = await updateQuery.write(AssetFaceEntityCompanion(personId: Value(targetPersonId))); + + // Delete merged persons + final deleteQuery = _db.delete(_db.personEntity)..where((row) => row.id.isIn(mergePersonIds)); + final deletedCount = await deleteQuery.go(); + + return updatedCount + deletedCount; + }); + } } extension on PersonEntityData { diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index 32bbd7e60b..a1608cfdd4 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -83,7 +83,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState createState() => _DriftPersonPageState(); @@ -23,30 +27,20 @@ class DriftPersonPage extends ConsumerStatefulWidget { class _DriftPersonPageState extends ConsumerState { late DriftPerson _person; + final Logger mergeLogger = Logger("PersonMerge"); + @override initState() { super.initState(); - _person = widget.person; + _person = widget.initialPerson; } Future handleEditName(BuildContext context) async { - final newName = await showNameEditModal(context, _person); - - if (newName != null && newName.isNotEmpty) { - setState(() { - _person = _person.copyWith(name: newName); - }); - } + await showNameEditModal(context, _person); } Future handleEditBirthday(BuildContext context) async { - final birthday = await showBirthdayEditModal(context, _person); - - if (birthday != null) { - setState(() { - _person = _person.copyWith(birthDate: birthday); - }); - } + await showBirthdayEditModal(context, _person); } void showOptionSheet(BuildContext context) { @@ -72,27 +66,90 @@ class _DriftPersonPageState extends ConsumerState { @override Widget build(BuildContext context) { - return ProviderScope( - overrides: [ - timelineServiceProvider.overrideWith((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) { - throw Exception('User must be logged in to view person timeline'); - } + final personAsync = ref.watch(driftGetPersonByIdProvider(_person.id)); + final mergeTracker = ref.read(personMergeTrackerProvider); + ref.watch(currentRouteNameProvider.select((name) => name ?? DriftPersonRoute.name)); - final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id); - ref.onDispose(timelineService.dispose); - return timelineService; - }), - ], - child: Timeline( - appBar: PersonSliverAppBar( - person: _person, - onNameTap: () => handleEditName(context), - onBirthdayTap: () => handleEditBirthday(context), - onShowOptions: () => showOptionSheet(context), - ), - ), + return personAsync.when( + data: (personByIdProvider) { + if (personByIdProvider == null) { + // Check if the person was merged and redirect if necessary + final targetPersonId = mergeTracker.getTargetPersonId(_person.id); + if (targetPersonId != null) { + bool isOnPersonDetailPage = ModalRoute.of(context)?.isCurrent ?? false; + + // Only redirect if we're currently on the person detail page, not in a nested view, e.g. image viewer + if (!isOnPersonDetailPage) { + return const Center(child: CircularProgressIndicator()); + } + // Person was merged, redirect to the target person + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref + .read(driftPeopleServiceProvider) + .watchPersonById(targetPersonId) + .first + .then((targetPerson) { + if (targetPerson != null && mounted) { + // Open the target person's page + if (mounted) { + ContextHelper(context).pop(); + context.pushRoute(DriftPersonRoute(initialPerson: targetPerson)); + } + } else { + // Target person not found, go back + context.maybePop(); + } + }) + .catchError((error) { + // If we can't load the target person, go back + mergeLogger.severe("Error during read of targetPerson", error); + if (mounted) { + context.maybePop(); + } + }); + } + }); + return const Center(child: CircularProgressIndicator()); + } else { + mergeLogger.info( + 'Person ${_person.name} (${_person.id}) not found and no merge records exist, it was probably deleted', + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.maybePop(); + } + }); + return const Center(child: CircularProgressIndicator()); + } + } + _person = personByIdProvider; + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith((ref) { + final user = ref.watch(currentUserProvider); + if (user == null) { + throw Exception('User must be logged in to view person timeline'); + } + + final timelineService = ref.read(timelineFactoryProvider).person(user.id, _person.id); + ref.onDispose(timelineService.dispose); + return timelineService; + }), + ], + child: Timeline( + appBar: PersonSliverAppBar( + person: _person, + onNameTap: () => handleEditName(context), + onBirthdayTap: () => handleEditBirthday(context), + onShowOptions: () => showOptionSheet(context), + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, s) => Text('Error: $e'), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 32bbc915a1..4a29ea76a3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -28,18 +27,6 @@ class PeopleDetails extends ConsumerWidget { final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id)); - Future showNameEditModal(DriftPerson person) async { - await showDialog( - context: context, - useRootNavigator: false, - builder: (BuildContext context) { - return DriftPersonNameEditForm(person: person); - }, - ); - - ref.invalidate(driftPeopleAssetProvider(asset.id)); - } - return peopleFuture.when( data: (people) { return AnimatedCrossFade( @@ -69,14 +56,35 @@ class PeopleDetails extends ConsumerWidget { final previousRouteArgs = previousRouteData?.arguments; // Prevent circular navigation - if (previousRouteArgs is DriftPersonRouteArgs && previousRouteArgs.person.id == person.id) { + if (previousRouteArgs is DriftPersonRouteArgs && + previousRouteArgs.initialPerson.id == person.id) { context.back(); return; } ContextHelper(context).pop(); - context.pushRoute(DriftPersonRoute(person: person)); + context.pushRoute(DriftPersonRoute(initialPerson: person)); + }, + onNameTap: () async { + // Needs to be before the modal, as this overwrites the previousRouteDataProvider + final previousRouteData = ref.read(previousRouteDataProvider); + final previousRouteArgs = previousRouteData?.arguments; + final previousPersonId = previousRouteArgs is DriftPersonRouteArgs + ? previousRouteArgs.initialPerson.id + : null; + + DriftPerson? newPerson = await showNameEditModal(context, person); + + // If the name edit resulted in a new person (e.g. from merging) + // And if we are currently nested below the drift person page if said + // old person id, we need to pop, otherwise the timeline provider complains + // and the asset viewer goes black + // TODO: Preferably we would replace the timeline provider, and let it listen to the new person id (Relevant function is the ```TimelineService person(String userId, String personId)``` in timeline.service.dart) + if (newPerson != null && newPerson.id != person.id && previousPersonId == person.id) { + await context.maybePop(); + } + + ref.invalidate(driftPeopleAssetProvider(asset.id)); }, - onNameTap: () => showNameEditModal(person), ), ], ), diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 7ed02af26b..9985ceb2fe 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -16,10 +16,10 @@ class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { const DriftPersonBirthdayEditForm({super.key, required this.person}); @override - ConsumerState createState() => _DriftPersonNameEditFormState(); + ConsumerState createState() => _DriftPersonBirthdayEditFormState(); } -class _DriftPersonNameEditFormState extends ConsumerState { +class _DriftPersonBirthdayEditFormState extends ConsumerState { late DateTime _selectedDate; @override diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 6de19000e0..cbbe9c1d53 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -5,9 +5,13 @@ 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/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/people/person_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/utils/people.utils.dart'; import 'package:immich_mobile/utils/debug_print.dart'; +import 'package:logging/logging.dart'; class DriftPersonNameEditForm extends ConsumerStatefulWidget { final DriftPerson person; @@ -20,6 +24,7 @@ class DriftPersonNameEditForm extends ConsumerStatefulWidget { class _DriftPersonNameEditFormState extends ConsumerState { late TextEditingController _formController; + List _filteredPeople = []; @override void initState() { @@ -27,12 +32,30 @@ class _DriftPersonNameEditFormState extends ConsumerState(response); + } + } + return; + } + + void onEdit(DriftPerson person, String newName) async { try { - final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName); + final result = await ref.read(driftPeopleServiceProvider).updateName(person.id, newName); if (result != 0) { ref.invalidate(driftGetAllPeopleProvider); - context.pop(newName); + if (mounted) { + context.pop(person); + } } } catch (error) { dPrint(() => 'Error updating name: $error'); @@ -50,16 +73,107 @@ class _DriftPersonNameEditFormState extends ConsumerState people, String query) { + final queryParts = query.toLowerCase().split(' ').where((e) => e.isNotEmpty).toList(); + + List startsWithMatches = []; + List containsMatches = []; + + for (final p in people) { + if (p.id == widget.person.id) continue; + + final nameParts = p.name.toLowerCase().split(' ').where((e) => e.isNotEmpty).toList(); + + final allStart = queryParts.every((q) => nameParts.any((n) => n.startsWith(q))); + final allContain = queryParts.every((q) => nameParts.any((n) => n.contains(q))); + + if (allStart) { + // Prioritize names (first or surname) that start with the query + startsWithMatches.add(p); + } else if (allContain) { + containsMatches.add(p); + } + } + + if (!mounted) return; + setState(() { + // TODO: What happens if there are more than 3 matches with the exact same name? + _filteredPeople = query.isEmpty ? [] : (startsWithMatches + containsMatches).take(3).toList(); + }); + } + @override Widget build(BuildContext context) { + final curatedPeople = ref.watch(driftGetAllPeopleProvider); + List people = []; + return AlertDialog( title: const Text("edit_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(), content: SingleChildScrollView( - child: TextFormField( - controller: _formController, - textCapitalization: TextCapitalization.words, - autofocus: true, - decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + autofocus: true, + controller: _formController, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + hintText: 'add_a_name'.tr(), + border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(8))), + ), + onChanged: (value) => _filterPeople(people, value), + onTapOutside: (event) => FocusScope.of(context).unfocus(), + ), + curatedPeople.when( + data: (p) { + people = p; + return AnimatedSize( + duration: const Duration(milliseconds: 200), + child: SizedBox( + width: double.infinity, + child: _filteredPeople.isEmpty + // Tile instead of a blank space to avoid horizontal layout shift + ? LargeLeadingTile( + leading: const SizedBox.shrink(), + onTap: () {}, + title: const SizedBox.shrink(), + disabled: true, + ) + : Container( + margin: const EdgeInsets.only(top: 8), + decoration: const BoxDecoration(borderRadius: BorderRadius.all(Radius.circular(8))), + child: Column( + mainAxisSize: MainAxisSize.min, + children: _filteredPeople.map((person) { + return PersonTile( + isSelected: false, + onTap: () { + if (!mounted) return; + setState(() { + _formController.text = person.name; + }); + _formController.selection = TextSelection.fromPosition( + TextPosition(offset: _formController.text.length), + ); + onMerge(context: context, person: widget.person, mergeTarget: person); + }, + personName: person.name, + personId: person.id, + ); + }).toList(), + ), + ), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) { + Logger('PersonEditNameModal').warning('Error loading people for name edit modal', err, stack); + return Center(child: Text('Error loading people for name edit modal: $err')); + }, + ), + ], ), ), actions: [ @@ -71,7 +185,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(widget.person.id, _formController.text), + onPressed: () => onEdit(widget.person, _formController.text), child: Text( "save", style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), diff --git a/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart new file mode 100644 index 0000000000..7f9bf5444a --- /dev/null +++ b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart @@ -0,0 +1,136 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class DriftPersonMergeForm extends ConsumerStatefulWidget { + final DriftPerson person; + final DriftPerson mergeTarget; + + const DriftPersonMergeForm({super.key, required this.person, required this.mergeTarget}); + + @override + ConsumerState createState() => _DriftPersonMergeFormState(); +} + +class _DriftPersonMergeFormState extends ConsumerState { + bool _isMerging = false; + + Future _mergePeople(BuildContext context) async { + setState(() => _isMerging = true); + try { + await ref + .read(driftPeopleServiceProvider) + .mergePeople(targetPersonId: widget.mergeTarget.id, mergePersonIds: [widget.person.id]); + + // Record the merge in the tracker service + ref + .read(personMergeTrackerProvider) + .recordMerge(mergedPersonId: widget.person.id, targetPersonId: widget.mergeTarget.id); + + if (mounted) { + Navigator.of(context).pop(widget.mergeTarget); + ImmichToast.show( + context: context, + msg: "merge_people_successfully".tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + } + ref.invalidate(driftGetAllPeopleProvider); + } catch (e) { + if (mounted) { + setState(() => _isMerging = false); + ImmichToast.show( + context: context, + msg: "error_title".tr(), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } + } + } + + @override + Widget build(BuildContext context) { + final headers = ApiService.getRequestHeaders(); + + return AlertDialog( + title: const Text("merge_people", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 32, + backgroundImage: NetworkImage(getFaceThumbnailUrl(widget.person.id), headers: headers), + ), + const SizedBox(width: 16), + const RotatedBox(quarterTurns: 1, child: Icon(Icons.merge_type, size: 32)), + const SizedBox(width: 16), + CircleAvatar( + radius: 32, + backgroundImage: NetworkImage(getFaceThumbnailUrl(widget.mergeTarget.id), headers: headers), + ), + ], + ), + const SizedBox(height: 24), + const Text( + "are_these_the_same_person", + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + textAlign: TextAlign.center, + ).tr(), + const SizedBox(height: 8), + const Text( + "they_will_be_merged_together", + style: TextStyle(fontSize: 14, color: Colors.black54), + textAlign: TextAlign.center, + ).tr(), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.onSurface, + foregroundColor: Theme.of(context).colorScheme.onInverseSurface, + elevation: 0, + ), + onPressed: _isMerging ? null : () => Navigator.of(context).pop(), + child: const Text("no", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + ), + onPressed: _isMerging ? null : () => _mergePeople(context), + child: _isMerging + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), + ) + : const Text("yes", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/people/person_tile.widget.dart b/mobile/lib/presentation/widgets/people/person_tile.widget.dart new file mode 100644 index 0000000000..bf5f6361f6 --- /dev/null +++ b/mobile/lib/presentation/widgets/people/person_tile.widget.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +// TODO: Only pass person object, instead of id and name when PersonDto and DriftPerson are unified +class PersonTile extends StatelessWidget { + final bool isSelected; + final String personId; + final String personName; + final double imageSize; + final Function() onTap; + + const PersonTile({ + super.key, + required this.isSelected, + required this.personId, + required this.personName, + this.imageSize = 60.0, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final headers = ApiService.getRequestHeaders(); + + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: LargeLeadingTile( + title: Text( + personName, + style: context.textTheme.bodyLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w500, + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + leading: SizedBox( + height: imageSize, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + maxRadius: imageSize / 2, + backgroundImage: NetworkImage(getFaceThumbnailUrl(personId), headers: headers), + ), + ), + ), + onTap: () => onTap(), + + selected: isSelected, + selectedTileColor: context.primaryColor, + tileColor: context.primaryColor.withAlpha(25), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 94a1b2447f..962e6da4ea 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/services/people.service.dart'; +import 'package:immich_mobile/domain/services/person_merge_tracker.service.dart'; import 'package:immich_mobile/infrastructure/repositories/people.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/repositories/person_api.repository.dart'; @@ -22,3 +23,9 @@ final driftGetAllPeopleProvider = FutureProvider>((ref) async final service = ref.watch(driftPeopleServiceProvider); return service.getAllPeople(); }); + +final driftGetPersonByIdProvider = StreamProvider.family((ref, personId) { + return ref.watch(driftPeopleServiceProvider).watchPersonById(personId); +}); + +final personMergeTrackerProvider = Provider((ref) => PersonMergeTrackerService()); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index bbf55e674a..35648893eb 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -23,6 +23,10 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } + Future?> merge(String targetId, List mergeIds) async { + return await checkNull(_api.mergePerson(targetId, MergePersonDto(ids: mergeIds))); + } + static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( birthDate: dto.birthDate, id: dto.id, diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4b538d789..caa22ae164 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -905,12 +905,12 @@ class DriftPeopleCollectionRoute extends PageRouteInfo { /// [DriftPersonPage] class DriftPersonRoute extends PageRouteInfo { DriftPersonRoute({ + required DriftPerson initialPerson, Key? key, - required DriftPerson person, List? children, }) : super( DriftPersonRoute.name, - args: DriftPersonRouteArgs(key: key, person: person), + args: DriftPersonRouteArgs(initialPerson: initialPerson, key: key), initialChildren: children, ); @@ -920,32 +920,32 @@ class DriftPersonRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return DriftPersonPage(key: args.key, person: args.person); + return DriftPersonPage(args.initialPerson, key: args.key); }, ); } class DriftPersonRouteArgs { - const DriftPersonRouteArgs({this.key, required this.person}); + const DriftPersonRouteArgs({required this.initialPerson, this.key}); + + final DriftPerson initialPerson; final Key? key; - final DriftPerson person; - @override String toString() { - return 'DriftPersonRouteArgs{key: $key, person: $person}'; + return 'DriftPersonRouteArgs{initialPerson: $initialPerson, key: $key}'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! DriftPersonRouteArgs) return false; - return key == other.key && person == other.person; + return initialPerson == other.initialPerson && key == other.key; } @override - int get hashCode => key.hashCode ^ person.hashCode; + int get hashCode => initialPerson.hashCode ^ key.hashCode; } /// generated route for diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 26f2fb685b..8a5267a112 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -147,6 +147,6 @@ class DeepLinkService { return null; } - return DriftPersonRoute(person: person); + return DriftPersonRoute(initialPerson: person); } } diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart index ddd1867269..7f4224b7ab 100644 --- a/mobile/lib/utils/people.utils.dart +++ b/mobile/lib/utils/people.utils.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; +import 'package:immich_mobile/presentation/widgets/people/person_merge_modal.widget.dart'; String formatAge(DateTime birthDate, DateTime referenceDate) { int ageInYears = _calculateAge(birthDate, referenceDate); @@ -33,8 +34,8 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) { (referenceDate.day < birthDate.day ? 1 : 0); } -Future showNameEditModal(BuildContext context, DriftPerson person) { - return showDialog( +Future showNameEditModal(BuildContext context, DriftPerson person) { + return showDialog( context: context, useRootNavigator: false, builder: (BuildContext context) { @@ -52,3 +53,13 @@ Future showBirthdayEditModal(BuildContext context, DriftPerson person }, ); } + +Future showMergeModal(BuildContext context, DriftPerson person, DriftPerson mergeTarget) { + return showDialog( + context: context, + useRootNavigator: false, + builder: (BuildContext context) { + return DriftPersonMergeForm(person: person, mergeTarget: mergeTarget); + }, + ); +} diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index a7b0286df3..05111786c1 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -5,10 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/pages/common/large_leading_tile.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/people/person_tile.widget.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { @@ -20,7 +18,7 @@ class PeoplePicker extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final formFocus = useFocusNode(); - final imageSize = 60.0; + final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); final selectedPeople = useState>(filter ?? {}); @@ -56,44 +54,19 @@ class PeoplePicker extends HookConsumerWidget { .toList()[index]; final isSelected = selectedPeople.value.contains(person); - return Padding( - key: ValueKey(person.id), - padding: const EdgeInsets.only(bottom: 2.0), - child: LargeLeadingTile( - title: Text( - person.name, - style: context.textTheme.bodyLarge?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w500, - color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, - ), - ), - leading: SizedBox( - height: imageSize, - child: Material( - shape: const CircleBorder(side: BorderSide.none), - elevation: 3, - child: CircleAvatar( - key: ValueKey(person.id), - maxRadius: imageSize / 2, - backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)), - ), - ), - ), - onTap: () { - if (selectedPeople.value.contains(person)) { - selectedPeople.value.remove(person); - } else { - selectedPeople.value.add(person); - } - - selectedPeople.value = {...selectedPeople.value}; - onSelect(selectedPeople.value); - }, - selected: isSelected, - selectedTileColor: context.primaryColor, - tileColor: context.primaryColor.withAlpha(25), - ), + return PersonTile( + isSelected: isSelected, + personId: person.id, + personName: person.name, + onTap: () { + if (selectedPeople.value.contains(person)) { + selectedPeople.value.remove(person); + } else { + selectedPeople.value.add(person); + } + selectedPeople.value = {...selectedPeople.value}; + onSelect(selectedPeople.value); + }, ); }, ); From 18328a9f422fecf6cae46d95cd70ed37f7720205 Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sat, 23 May 2026 15:20:26 +0200 Subject: [PATCH 2/6] fix: replace NetworkImage with RemoteImageProvider in person merge and tile widgets --- .../widgets/people/person_merge_modal.widget.dart | 12 +++++------- .../widgets/people/person_tile.widget.dart | 6 ++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart index 7f9bf5444a..ec41fa5db1 100644 --- a/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart @@ -4,9 +4,9 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; class DriftPersonMergeForm extends ConsumerStatefulWidget { final DriftPerson person; @@ -58,8 +58,6 @@ class _DriftPersonMergeFormState extends ConsumerState { @override Widget build(BuildContext context) { - final headers = ApiService.getRequestHeaders(); - return AlertDialog( title: const Text("merge_people", style: TextStyle(fontWeight: FontWeight.bold)).tr(), content: Column( @@ -70,14 +68,14 @@ class _DriftPersonMergeFormState extends ConsumerState { children: [ CircleAvatar( radius: 32, - backgroundImage: NetworkImage(getFaceThumbnailUrl(widget.person.id), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(widget.person.id)), ), const SizedBox(width: 16), const RotatedBox(quarterTurns: 1, child: Icon(Icons.merge_type, size: 32)), const SizedBox(width: 16), CircleAvatar( radius: 32, - backgroundImage: NetworkImage(getFaceThumbnailUrl(widget.mergeTarget.id), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(widget.mergeTarget.id)), ), ], ), @@ -88,9 +86,9 @@ class _DriftPersonMergeFormState extends ConsumerState { textAlign: TextAlign.center, ).tr(), const SizedBox(height: 8), - const Text( + Text( "they_will_be_merged_together", - style: TextStyle(fontSize: 14, color: Colors.black54), + style: TextStyle(fontSize: 14, color: Theme.of(context).textTheme.bodyMedium?.color), textAlign: TextAlign.center, ).tr(), const SizedBox(height: 24), diff --git a/mobile/lib/presentation/widgets/people/person_tile.widget.dart b/mobile/lib/presentation/widgets/people/person_tile.widget.dart index bf5f6361f6..c80904e6c1 100644 --- a/mobile/lib/presentation/widgets/people/person_tile.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_tile.widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; // TODO: Only pass person object, instead of id and name when PersonDto and DriftPerson are unified class PersonTile extends StatelessWidget { @@ -23,8 +23,6 @@ class PersonTile extends StatelessWidget { @override Widget build(BuildContext context) { - final headers = ApiService.getRequestHeaders(); - return Padding( padding: const EdgeInsets.only(bottom: 2.0), child: LargeLeadingTile( @@ -43,7 +41,7 @@ class PersonTile extends StatelessWidget { elevation: 3, child: CircleAvatar( maxRadius: imageSize / 2, - backgroundImage: NetworkImage(getFaceThumbnailUrl(personId), headers: headers), + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId)), ), ), ), From 583930ffdb9cc9875f944b3892b35153d50d756c Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sat, 23 May 2026 15:38:08 +0200 Subject: [PATCH 3/6] fix: adjust text styling and padding in PersonTile widget --- .../widgets/people/person_tile.widget.dart | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/mobile/lib/presentation/widgets/people/person_tile.widget.dart b/mobile/lib/presentation/widgets/people/person_tile.widget.dart index c80904e6c1..69adf207bd 100644 --- a/mobile/lib/presentation/widgets/people/person_tile.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_tile.widget.dart @@ -26,12 +26,15 @@ class PersonTile extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 2.0), child: LargeLeadingTile( - title: Text( - personName, - style: context.textTheme.bodyLarge?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w500, - color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + title: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + personName, + style: context.textTheme.bodyLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), ), ), leading: SizedBox( @@ -46,7 +49,6 @@ class PersonTile extends StatelessWidget { ), ), onTap: () => onTap(), - selected: isSelected, selectedTileColor: context.primaryColor, tileColor: context.primaryColor.withAlpha(25), From 9221fe85d7b1e2cd557907ee882d08ce436780e4 Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sat, 23 May 2026 23:06:50 +0200 Subject: [PATCH 4/6] refactor: remove PersonMergeTrackerService and refactor routing logic --- .../person_merge_tracker.service.dart | 24 ------ .../presentation/pages/drift_person.page.dart | 86 ++++++------------- .../people/person_merge_modal.widget.dart | 5 -- .../infrastructure/people.provider.dart | 3 - 4 files changed, 28 insertions(+), 90 deletions(-) delete mode 100644 mobile/lib/domain/services/person_merge_tracker.service.dart diff --git a/mobile/lib/domain/services/person_merge_tracker.service.dart b/mobile/lib/domain/services/person_merge_tracker.service.dart deleted file mode 100644 index 6347e14f37..0000000000 --- a/mobile/lib/domain/services/person_merge_tracker.service.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// Why do we need this? -/// Say we open the profile page (drift_person.page.dart) for Person A, and then nested above -/// a image viewer for an image that belongs to Person A. -/// -/// When the users now merges user A into user B, we can't just listen to -/// the changes in the profile page, we have to keep track of where the user A (now B) -/// can be found in the DB. -/// -/// So when popping back to the profile page (and the user is missing), we check -/// which other person B we have to display instead. -class PersonMergeTrackerService { - /// Map of merged person ID -> target person ID - final Map _mergeForwardingMap = {}; - - /// Record a person merge operation - void recordMerge({required String mergedPersonId, required String targetPersonId}) { - _mergeForwardingMap[mergedPersonId] = targetPersonId; - } - - /// Get the target person ID for a merged person - String? getTargetPersonId(String personId) { - return _mergeForwardingMap[personId]; - } -} diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart index d57633a928..819cf91da5 100644 --- a/mobile/lib/presentation/pages/drift_person.page.dart +++ b/mobile/lib/presentation/pages/drift_person.page.dart @@ -12,7 +12,6 @@ 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'; -import 'package:logging/logging.dart'; @RoutePage() class DriftPersonPage extends ConsumerStatefulWidget { @@ -27,16 +26,23 @@ class DriftPersonPage extends ConsumerStatefulWidget { class _DriftPersonPageState extends ConsumerState { late DriftPerson _person; - final Logger mergeLogger = Logger("PersonMerge"); - @override - initState() { + void initState() { super.initState(); _person = widget.initialPerson; } - Future handleEditName(BuildContext context) async { - await showNameEditModal(context, _person); + Future handleEditName(BuildContext context) async { + final result = await showNameEditModal(context, _person); + + // result is not null && different person => merge occurred + if (result != null && result.id != _person.id && mounted) { + setState(() => _person = result); + + await this.context.replaceRoute(DriftPersonRoute(initialPerson: result)); + return true; + } + return false; } Future handleEditBirthday(BuildContext context) async { @@ -51,8 +57,10 @@ class _DriftPersonPageState extends ConsumerState { builder: (context) { return PersonOptionSheet( onEditName: () async { - await handleEditName(context); - ContextHelper(context).pop(); + final isMerge = await handleEditName(context); + if (!isMerge) { + ContextHelper(context).pop(); + } }, onEditBirthday: () async { await handleEditBirthday(context); @@ -67,63 +75,25 @@ class _DriftPersonPageState extends ConsumerState { @override Widget build(BuildContext context) { final personAsync = ref.watch(driftGetPersonByIdProvider(_person.id)); - final mergeTracker = ref.read(personMergeTrackerProvider); ref.watch(currentRouteNameProvider.select((name) => name ?? DriftPersonRoute.name)); return personAsync.when( data: (personByIdProvider) { if (personByIdProvider == null) { - // Check if the person was merged and redirect if necessary - final targetPersonId = mergeTracker.getTargetPersonId(_person.id); - if (targetPersonId != null) { - bool isOnPersonDetailPage = ModalRoute.of(context)?.isCurrent ?? false; - - // Only redirect if we're currently on the person detail page, not in a nested view, e.g. image viewer - if (!isOnPersonDetailPage) { - return const Center(child: CircularProgressIndicator()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final currentRoute = AutoRouter.of(context).current; + // Check if we are currently on the DriftPersonRoute that corresponds to the deleted _person + if (currentRoute.name == DriftPersonRoute.name && + (currentRoute.args is DriftPersonRouteArgs && + (currentRoute.args as DriftPersonRouteArgs).initialPerson.id == _person.id)) { + AutoRouter.of(context).replace(const DriftPeopleCollectionRoute()); + } } - // Person was merged, redirect to the target person - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - ref - .read(driftPeopleServiceProvider) - .watchPersonById(targetPersonId) - .first - .then((targetPerson) { - if (targetPerson != null && mounted) { - // Open the target person's page - if (mounted) { - ContextHelper(context).pop(); - context.pushRoute(DriftPersonRoute(initialPerson: targetPerson)); - } - } else { - // Target person not found, go back - context.maybePop(); - } - }) - .catchError((error) { - // If we can't load the target person, go back - mergeLogger.severe("Error during read of targetPerson", error); - if (mounted) { - context.maybePop(); - } - }); - } - }); - return const Center(child: CircularProgressIndicator()); - } else { - mergeLogger.info( - 'Person ${_person.name} (${_person.id}) not found and no merge records exist, it was probably deleted', - ); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - context.maybePop(); - } - }); - return const Center(child: CircularProgressIndicator()); - } + }); + return const Center(child: CircularProgressIndicator()); } + _person = personByIdProvider; return ProviderScope( overrides: [ diff --git a/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart index ec41fa5db1..86837d13b5 100644 --- a/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart @@ -28,11 +28,6 @@ class _DriftPersonMergeFormState extends ConsumerState { .read(driftPeopleServiceProvider) .mergePeople(targetPersonId: widget.mergeTarget.id, mergePersonIds: [widget.person.id]); - // Record the merge in the tracker service - ref - .read(personMergeTrackerProvider) - .recordMerge(mergedPersonId: widget.person.id, targetPersonId: widget.mergeTarget.id); - if (mounted) { Navigator.of(context).pop(widget.mergeTarget); ImmichToast.show( diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 962e6da4ea..7a1caa2891 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -1,7 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/services/people.service.dart'; -import 'package:immich_mobile/domain/services/person_merge_tracker.service.dart'; import 'package:immich_mobile/infrastructure/repositories/people.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/repositories/person_api.repository.dart'; @@ -27,5 +26,3 @@ final driftGetAllPeopleProvider = FutureProvider>((ref) async final driftGetPersonByIdProvider = StreamProvider.family((ref, personId) { return ref.watch(driftPeopleServiceProvider).watchPersonById(personId); }); - -final personMergeTrackerProvider = Provider((ref) => PersonMergeTrackerService()); From a872a2fc3bade2849e5049603adb788f725a020e Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sat, 23 May 2026 23:28:03 +0200 Subject: [PATCH 5/6] fix: add unique key to ProviderScope for DriftPersonPage --- mobile/lib/presentation/pages/drift_person.page.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart index 819cf91da5..5f13f8d020 100644 --- a/mobile/lib/presentation/pages/drift_person.page.dart +++ b/mobile/lib/presentation/pages/drift_person.page.dart @@ -96,6 +96,7 @@ class _DriftPersonPageState extends ConsumerState { _person = personByIdProvider; return ProviderScope( + key: ValueKey(_person.id), overrides: [ timelineServiceProvider.overrideWith((ref) { final user = ref.watch(currentUserProvider); From d9851fc55912650fe997e2e6cd7628ed3ee24244 Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sat, 23 May 2026 23:39:59 +0200 Subject: [PATCH 6/6] fix: linting --- .../people/person_edit_name_modal.widget.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index cbbe9c1d53..2de6804942 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -81,7 +81,9 @@ class _DriftPersonNameEditFormState extends ConsumerState containsMatches = []; for (final p in people) { - if (p.id == widget.person.id) continue; + if (p.id == widget.person.id) { + continue; + } final nameParts = p.name.toLowerCase().split(' ').where((e) => e.isNotEmpty).toList(); @@ -96,7 +98,10 @@ class _DriftPersonNameEditFormState extends ConsumerState