From 718dcafdd0a574efe83f5d73c3ffe7d05b1ef58f Mon Sep 17 00:00:00 2001 From: ashilkn Date: Thu, 23 Jan 2025 11:42:04 +0530 Subject: [PATCH] [mob][photos] Make 'Link person' banner on contact screen functional. Create a separate widget for ContactSearchResult for better separation. --- .../person_contact_linking_actions.dart | 49 +++++ ...link_contact_to_person_selection_page.dart | 193 ++++++++++++++++++ .../search/result/contact_result_page.dart | 178 ++++++++++++++++ .../search/result/search_result_page.dart | 15 -- .../search/result/search_result_widget.dart | 26 ++- .../viewer/search/result/searchable_item.dart | 29 ++- .../viewer/search_tab/contacts_section.dart | 4 +- 7 files changed, 460 insertions(+), 34 deletions(-) create mode 100644 mobile/lib/ui/viewer/people/link_contact_to_person_selection_page.dart create mode 100644 mobile/lib/ui/viewer/search/result/contact_result_page.dart diff --git a/mobile/lib/ui/actions/person_contact_linking_actions.dart b/mobile/lib/ui/actions/person_contact_linking_actions.dart index 51ea4a5b58..266150d1fb 100644 --- a/mobile/lib/ui/actions/person_contact_linking_actions.dart +++ b/mobile/lib/ui/actions/person_contact_linking_actions.dart @@ -152,4 +152,53 @@ class PersonContactLinkingActions { return false; } } + + Future linkPersonToContact( + BuildContext context, { + required String emailToLink, + required PersonEntity personEntity, + }) async { + final personName = personEntity.data.name; + PersonEntity? updatedPerson; + final result = await showDialogWidget( + context: context, + title: "Link person to $emailToLink", + icon: Icons.info_outline, + body: "This will link $personName to $emailToLink", + isDismissible: true, + buttons: [ + ButtonWidget( + buttonAction: ButtonAction.first, + buttonType: ButtonType.neutral, + labelText: "Link", + isInAlert: true, + onTap: () async { + updatedPerson = await PersonService.instance + .updateAttributes(personEntity.remoteID, email: emailToLink); + Bus.instance.fire( + PeopleChangedEvent( + type: PeopleEventType.saveOrEditPerson, + source: "linkPersonToContact", + person: updatedPerson, + ), + ); + }, + ), + ButtonWidget( + buttonAction: ButtonAction.cancel, + buttonType: ButtonType.secondary, + labelText: S.of(context).cancel, + isInAlert: true, + ), + ], + ); + + if (result?.exception != null) { + _logger.severe("Failed to link person to contact", result!.exception); + await showGenericErrorDialog(context: context, error: result.exception); + return null; + } else { + return updatedPerson; + } + } } diff --git a/mobile/lib/ui/viewer/people/link_contact_to_person_selection_page.dart b/mobile/lib/ui/viewer/people/link_contact_to_person_selection_page.dart new file mode 100644 index 0000000000..27f9e4c203 --- /dev/null +++ b/mobile/lib/ui/viewer/people/link_contact_to_person_selection_page.dart @@ -0,0 +1,193 @@ +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/models/ml/face/person.dart"; +import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; +import "package:photos/services/machine_learning/ml_result.dart"; +import "package:photos/services/search_service.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/actions/person_contact_linking_actions.dart"; +import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/viewer/search/result/person_face_widget.dart"; + +class PersonEntityWithThumbnailFile { + final PersonEntity person; + final EnteFile thumbnailFile; + + const PersonEntityWithThumbnailFile( + this.person, + this.thumbnailFile, + ); +} + +class LinkContactToPersonSelectionPage extends StatefulWidget { + final bool isFromReassignMe; + final String? emailToLink; + const LinkContactToPersonSelectionPage({ + this.isFromReassignMe = false, + this.emailToLink, + super.key, + }) : assert(!isFromReassignMe ? emailToLink != null : true); + + @override + State createState() => + _LinkContactToPersonSelectionPageState(); +} + +class _LinkContactToPersonSelectionPageState + extends State { + late Future> + _personEntitiesWithThumnailFile; + + @override + void initState() { + super.initState(); + + _personEntitiesWithThumnailFile = + PersonService.instance.getPersons().then((persons) async { + final List result = []; + for (final person in persons) { + if (person.data.email != null && person.data.email!.isNotEmpty) { + continue; + } + final file = await _getRecentFile(person); + result.add(PersonEntityWithThumbnailFile(person, file)); + } + return result; + }); + } + + @override + Widget build(BuildContext context) { + final smallFontSize = getEnteTextTheme(context).small.fontSize!; + final textScaleFactor = + MediaQuery.textScalerOf(context).scale(smallFontSize) / smallFontSize; + const horizontalEdgePadding = 20.0; + const gridPadding = 16.0; + return Scaffold( + appBar: AppBar( + title: Text( + widget.isFromReassignMe + ? "Select your face" + : "Select person to link", + ), + centerTitle: false, + ), + body: FutureBuilder>( + future: _personEntitiesWithThumnailFile, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: EnteLoadingWidget()); + } else if (snapshot.hasError) { + return const Center(child: Icon(Icons.error_outline_rounded)); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Center(child: Text(S.of(context).noResultsFound + '.')); + } else { + final results = snapshot.data!; + final screenWidth = MediaQuery.of(context).size.width; + final crossAxisCount = (screenWidth / 100).floor(); + + final itemSize = (screenWidth - + ((horizontalEdgePadding * 2) + + ((crossAxisCount - 1) * gridPadding))) / + crossAxisCount; + + return GridView.builder( + padding: const EdgeInsets.fromLTRB( + horizontalEdgePadding, + 16, + horizontalEdgePadding, + 96, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + mainAxisSpacing: gridPadding, + crossAxisSpacing: gridPadding, + crossAxisCount: crossAxisCount, + childAspectRatio: + itemSize / (itemSize + (24 * textScaleFactor)), + ), + itemCount: results.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () { + if (widget.isFromReassignMe) { + Navigator.of(context).pop(results[index].person); + } else { + PersonContactLinkingActions() + .linkPersonToContact( + context, + emailToLink: widget.emailToLink!, + personEntity: results[index].person, + ) + .then((updatedPerson) { + if (updatedPerson != null) { + Navigator.of(context).pop(updatedPerson); + } + }); + } + }, + child: PersonFaceWidget( + results[index].thumbnailFile, + personId: results[index].person.remoteID, + useFullFile: true, + ), + ); + // return PersonSearchExample( + // searchResult: results[index], + // size: itemSize, + // ) + // .animate(delay: Duration(milliseconds: index * 13)) + // .fadeIn( + // duration: const Duration(milliseconds: 225), + // curve: Curves.easeIn, + // ) + // .slide( + // begin: const Offset(0, -0.06), + // curve: Curves.easeInOut, + // duration: const Duration( + // milliseconds: 225, + // ), + // ); + }, + ); + } + }, + ), + ); + } + + Future _getRecentFile( + PersonEntity person, + ) async { + final clustersToFiles = + await SearchService.instance.getClusterFilesForPersonID( + person.remoteID, + ); + int? avatarFileID; + if (person.data.hasAvatar()) { + avatarFileID = tryGetFileIdFromFaceId(person.data.avatarFaceID!); + } + EnteFile? resultFile; + // iterate over all clusters and get the first file + for (final clusterFiles in clustersToFiles.values) { + for (final file in clusterFiles) { + if (avatarFileID != null && file.uploadedFileID! == avatarFileID) { + resultFile = file; + break; + } + resultFile ??= file; + if (resultFile.creationTime! < file.creationTime!) { + resultFile = file; + } + } + } + if (resultFile == null) { + debugPrint( + "Person ${kDebugMode ? person.data.name : person.remoteID} has no files", + ); + return EnteFile(); + } + return resultFile; + } +} diff --git a/mobile/lib/ui/viewer/search/result/contact_result_page.dart b/mobile/lib/ui/viewer/search/result/contact_result_page.dart new file mode 100644 index 0000000000..2703edf415 --- /dev/null +++ b/mobile/lib/ui/viewer/search/result/contact_result_page.dart @@ -0,0 +1,178 @@ +import "dart:async"; + +import "package:email_validator/email_validator.dart"; +import 'package:flutter/material.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/events/files_updated_event.dart'; +import 'package:photos/events/local_photos_updated_event.dart'; +import 'package:photos/models/file/file.dart'; +import 'package:photos/models/file_load_result.dart'; +import 'package:photos/models/gallery_type.dart'; +import "package:photos/models/ml/face/person.dart"; +import 'package:photos/models/search/search_result.dart'; +import 'package:photos/models/selected_files.dart'; +import "package:photos/ui/components/end_to_end_banner.dart"; +import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart'; +import 'package:photos/ui/viewer/gallery/gallery.dart'; +import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; +import "package:photos/ui/viewer/gallery/hierarchical_search_gallery.dart"; +import "package:photos/ui/viewer/gallery/state/gallery_files_inherited_widget.dart"; +import "package:photos/ui/viewer/gallery/state/inherited_search_filter_data.dart"; +import "package:photos/ui/viewer/gallery/state/search_filter_data_provider.dart"; +import "package:photos/ui/viewer/gallery/state/selection_state.dart"; +import "package:photos/ui/viewer/people/link_contact_to_person_selection_page.dart"; +import "package:photos/utils/navigation_util.dart"; + +class ContactResultPage extends StatefulWidget { + final SearchResult searchResult; + final bool enableGrouping; + final String tagPrefix; + + static const GalleryType appBarType = GalleryType.searchResults; + static const GalleryType overlayType = GalleryType.searchResults; + + const ContactResultPage( + this.searchResult, { + this.enableGrouping = true, + this.tagPrefix = "", + super.key, + }); + + @override + State createState() => _ContactResultPageState(); +} + +class _ContactResultPageState extends State { + final _selectedFiles = SelectedFiles(); + late final List files; + late final StreamSubscription _filesUpdatedEvent; + late String _searchResultName; + + @override + void initState() { + super.initState(); + files = widget.searchResult.resultFiles(); + _searchResultName = widget.searchResult.name(); + _filesUpdatedEvent = + Bus.instance.on().listen((event) { + if (event.type == EventType.deletedFromDevice || + event.type == EventType.deletedFromEverywhere || + event.type == EventType.deletedFromRemote || + event.type == EventType.hide) { + for (var updatedFile in event.updatedFiles) { + files.remove(updatedFile); + } + setState(() {}); + } + }); + } + + @override + void dispose() { + _filesUpdatedEvent.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final gallery = Gallery( + asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) { + final result = files + .where( + (file) => + file.creationTime! >= creationStartTime && + file.creationTime! <= creationEndTime, + ) + .toList(); + return Future.value( + FileLoadResult( + result, + result.length < files.length, + ), + ); + }, + reloadEvent: Bus.instance.on(), + removalEventTypes: const { + EventType.deletedFromRemote, + EventType.deletedFromEverywhere, + EventType.hide, + }, + tagPrefix: widget.tagPrefix + widget.searchResult.heroTag(), + selectedFiles: _selectedFiles, + enableFileGrouping: widget.enableGrouping, + initialFiles: [widget.searchResult.resultFiles().first], + header: EmailValidator.validate(_searchResultName) + ? Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: EndToEndBanner( + title: "Link person", + caption: "for better sharing experience", + leadingIcon: Icons.person, + onTap: () async { + final PersonEntity? updatedPerson = await routeToPage( + context, + LinkContactToPersonSelectionPage( + emailToLink: _searchResultName, + ), + ); + if (updatedPerson != null) { + setState(() { + _searchResultName = updatedPerson.data.name; + }); + } + }, + ), + ) + : null, + ); + + return GalleryFilesState( + child: InheritedSearchFilterDataWrapper( + searchFilterDataProvider: SearchFilterDataProvider( + initialGalleryFilter: + widget.searchResult.getHierarchicalSearchFilter(), + ), + child: Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(90.0), + child: GalleryAppBarWidget( + key: ValueKey(_searchResultName), + ContactResultPage.appBarType, + _searchResultName, + _selectedFiles, + ), + ), + body: SelectionState( + selectedFiles: _selectedFiles, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Builder( + builder: (context) { + return ValueListenableBuilder( + valueListenable: InheritedSearchFilterData.of(context) + .searchFilterDataProvider! + .isSearchingNotifier, + builder: (context, value, _) { + return value + ? HierarchicalSearchGallery( + tagPrefix: widget.tagPrefix, + selectedFiles: _selectedFiles, + ) + : gallery; + }, + ); + }, + ), + FileSelectionOverlayBar( + ContactResultPage.overlayType, + _selectedFiles, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/ui/viewer/search/result/search_result_page.dart b/mobile/lib/ui/viewer/search/result/search_result_page.dart index 2ae15a5ad6..19a3c7f25e 100644 --- a/mobile/lib/ui/viewer/search/result/search_result_page.dart +++ b/mobile/lib/ui/viewer/search/result/search_result_page.dart @@ -1,6 +1,5 @@ import "dart:async"; -import "package:email_validator/email_validator.dart"; import 'package:flutter/material.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/events/files_updated_event.dart'; @@ -9,9 +8,7 @@ import 'package:photos/models/file/file.dart'; import 'package:photos/models/file_load_result.dart'; import 'package:photos/models/gallery_type.dart'; import 'package:photos/models/search/search_result.dart'; -import "package:photos/models/search/search_types.dart"; import 'package:photos/models/selected_files.dart'; -import "package:photos/ui/components/end_to_end_banner.dart"; import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart'; import 'package:photos/ui/viewer/gallery/gallery.dart'; import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart'; @@ -97,18 +94,6 @@ class _SearchResultPageState extends State { selectedFiles: _selectedFiles, enableFileGrouping: widget.enableGrouping, initialFiles: [widget.searchResult.resultFiles().first], - header: widget.searchResult.type() == ResultType.shared && - EmailValidator.validate(widget.searchResult.name()) - ? Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: EndToEndBanner( - title: "Link person", - caption: "for better sharing experience", - leadingIcon: Icons.person, - onTap: () async {}, - ), - ) - : null, ); return GalleryFilesState( diff --git a/mobile/lib/ui/viewer/search/result/search_result_widget.dart b/mobile/lib/ui/viewer/search/result/search_result_widget.dart index 6dacab459a..8b50eff391 100644 --- a/mobile/lib/ui/viewer/search/result/search_result_widget.dart +++ b/mobile/lib/ui/viewer/search/result/search_result_widget.dart @@ -5,6 +5,7 @@ import "package:photos/models/search/recent_searches.dart"; import 'package:photos/models/search/search_result.dart'; import "package:photos/models/search/search_types.dart"; import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/viewer/search/result/contact_result_page.dart"; import 'package:photos/ui/viewer/search/result/search_result_page.dart'; import 'package:photos/ui/viewer/search/result/search_thumbnail_widget.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -16,10 +17,10 @@ class SearchResultWidget extends StatelessWidget { const SearchResultWidget( this.searchResult, { - Key? key, + super.key, this.resultCount, this.onResultTap, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -106,12 +107,21 @@ class SearchResultWidget extends StatelessWidget { if (onResultTap != null) { onResultTap!(); } else { - routeToPage( - context, - SearchResultPage( - searchResult, - ), - ); + if (searchResult.type() == ResultType.shared) { + routeToPage( + context, + ContactResultPage( + searchResult, + ), + ); + } else { + routeToPage( + context, + SearchResultPage( + searchResult, + ), + ); + } } }, ); diff --git a/mobile/lib/ui/viewer/search/result/searchable_item.dart b/mobile/lib/ui/viewer/search/result/searchable_item.dart index f8e2ed1acb..462e88aa0b 100644 --- a/mobile/lib/ui/viewer/search/result/searchable_item.dart +++ b/mobile/lib/ui/viewer/search/result/searchable_item.dart @@ -5,6 +5,7 @@ import "package:photos/models/search/search_result.dart"; import "package:photos/models/search/search_types.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/buttons/icon_button_widget.dart"; +import "package:photos/ui/viewer/search/result/contact_result_page.dart"; import "package:photos/ui/viewer/search/result/search_result_page.dart"; import "package:photos/ui/viewer/search/result/search_thumbnail_widget.dart"; import "package:photos/utils/navigation_util.dart"; @@ -15,10 +16,10 @@ class SearchableItemWidget extends StatelessWidget { final Function? onResultTap; const SearchableItemWidget( this.searchResult, { - Key? key, + super.key, this.resultCount, this.onResultTap, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -39,13 +40,23 @@ class SearchableItemWidget extends StatelessWidget { if (onResultTap != null) { onResultTap!(); } else { - routeToPage( - context, - SearchResultPage( - searchResult, - tagPrefix: additionalPrefix, - ), - ); + if (searchResult.type() == ResultType.shared) { + routeToPage( + context, + ContactResultPage( + searchResult, + tagPrefix: additionalPrefix, + ), + ); + } else { + routeToPage( + context, + SearchResultPage( + searchResult, + tagPrefix: additionalPrefix, + ), + ); + } } }, child: Container( diff --git a/mobile/lib/ui/viewer/search_tab/contacts_section.dart b/mobile/lib/ui/viewer/search_tab/contacts_section.dart index c2a34b780b..dc52a5b5fe 100644 --- a/mobile/lib/ui/viewer/search_tab/contacts_section.dart +++ b/mobile/lib/ui/viewer/search_tab/contacts_section.dart @@ -11,7 +11,7 @@ import "package:photos/models/search/search_types.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/viewer/file/no_thumbnail_widget.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; -import "package:photos/ui/viewer/search/result/search_result_page.dart"; +import "package:photos/ui/viewer/search/result/contact_result_page.dart"; import "package:photos/ui/viewer/search/search_section_cta.dart"; import "package:photos/ui/viewer/search_tab/section_header.dart"; import "package:photos/utils/navigation_util.dart"; @@ -148,7 +148,7 @@ class ContactRecommendation extends StatelessWidget { } else { routeToPage( context, - SearchResultPage(contactSearchResult), + ContactResultPage(contactSearchResult), ); } },