[mob][photos] Make 'Link person' banner on contact screen functional. Create a separate widget for ContactSearchResult for better separation.

This commit is contained in:
ashilkn
2025-01-23 11:42:04 +05:30
parent 45b2f91da5
commit 718dcafdd0
7 changed files with 460 additions and 34 deletions

View File

@@ -152,4 +152,53 @@ class PersonContactLinkingActions {
return false;
}
}
Future<PersonEntity?> 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;
}
}
}

View File

@@ -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<LinkContactToPersonSelectionPage> createState() =>
_LinkContactToPersonSelectionPageState();
}
class _LinkContactToPersonSelectionPageState
extends State<LinkContactToPersonSelectionPage> {
late Future<List<PersonEntityWithThumbnailFile>>
_personEntitiesWithThumnailFile;
@override
void initState() {
super.initState();
_personEntitiesWithThumnailFile =
PersonService.instance.getPersons().then((persons) async {
final List<PersonEntityWithThumbnailFile> 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<List<PersonEntityWithThumbnailFile>>(
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<EnteFile> _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;
}
}

View File

@@ -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<ContactResultPage> createState() => _ContactResultPageState();
}
class _ContactResultPageState extends State<ContactResultPage> {
final _selectedFiles = SelectedFiles();
late final List<EnteFile> files;
late final StreamSubscription<LocalPhotosUpdatedEvent> _filesUpdatedEvent;
late String _searchResultName;
@override
void initState() {
super.initState();
files = widget.searchResult.resultFiles();
_searchResultName = widget.searchResult.name();
_filesUpdatedEvent =
Bus.instance.on<LocalPhotosUpdatedEvent>().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<LocalPhotosUpdatedEvent>(),
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,
),
],
),
),
),
),
);
}
}

View File

@@ -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<SearchResultPage> {
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(

View File

@@ -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,
),
);
}
}
},
);

View File

@@ -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(

View File

@@ -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),
);
}
},