[mob][photos] Make 'Link person' banner on contact screen functional. Create a separate widget for ContactSearchResult for better separation.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
178
mobile/lib/ui/viewer/search/result/contact_result_page.dart
Normal file
178
mobile/lib/ui/viewer/search/result/contact_result_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user