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/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(); @@ -24,29 +27,26 @@ class _DriftPersonPageState extends ConsumerState { late DriftPerson _person; @override - initState() { + void initState() { super.initState(); - _person = widget.person; + _person = widget.initialPerson; } - Future handleEditName(BuildContext context) async { - final newName = await showNameEditModal(context, _person); + Future handleEditName(BuildContext context) async { + final result = await showNameEditModal(context, _person); - if (newName != null && newName.isNotEmpty) { - setState(() { - _person = _person.copyWith(name: newName); - }); + // 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 { - final birthday = await showBirthdayEditModal(context, _person); - - if (birthday != null) { - setState(() { - _person = _person.copyWith(birthDate: birthday); - }); - } + await showBirthdayEditModal(context, _person); } void showOptionSheet(BuildContext context) { @@ -57,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); @@ -72,27 +74,53 @@ 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)); + 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) { + 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()); + } + } + }); + return const Center(child: CircularProgressIndicator()); + } + + _person = personByIdProvider; + return ProviderScope( + key: ValueKey(_person.id), + 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..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 @@ -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,114 @@ 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 +192,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..86837d13b5 --- /dev/null +++ b/mobile/lib/presentation/widgets/people/person_merge_modal.widget.dart @@ -0,0 +1,129 @@ +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/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; + 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]); + + 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) { + 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: 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: RemoteImageProvider(url: getFaceThumbnailUrl(widget.mergeTarget.id)), + ), + ], + ), + 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), + Text( + "they_will_be_merged_together", + style: TextStyle(fontSize: 14, color: Theme.of(context).textTheme.bodyMedium?.color), + 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..69adf207bd --- /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/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 { + 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) { + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: LargeLeadingTile( + 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( + height: imageSize, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + maxRadius: imageSize / 2, + backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId)), + ), + ), + ), + 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..7a1caa2891 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -22,3 +22,7 @@ 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); +}); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 26662601b7..6f99f013e9 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -26,6 +26,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); + }, ); }, );