From 2baeaf11192e77f79a046782c57c8aac7fa93aa2 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Sat, 21 Jun 2025 15:05:37 +0530 Subject: [PATCH] Make people all page selectable --- .../people_action_bar_widget.dart | 86 +++++ .../people_bottom_action_bar_widget.dart | 57 +++ .../people_selection_action_widget.dart | 274 +++++++++++++++ .../result/people_section_all_page.dart | 331 +++++++++++++++++- 4 files changed, 731 insertions(+), 17 deletions(-) create mode 100644 mobile/lib/ui/components/bottom_action_bar/people_action_bar_widget.dart create mode 100644 mobile/lib/ui/components/bottom_action_bar/people_bottom_action_bar_widget.dart create mode 100644 mobile/lib/ui/viewer/actions/people_selection_action_widget.dart diff --git a/mobile/lib/ui/components/bottom_action_bar/people_action_bar_widget.dart b/mobile/lib/ui/components/bottom_action_bar/people_action_bar_widget.dart new file mode 100644 index 0000000000..8370d917d7 --- /dev/null +++ b/mobile/lib/ui/components/bottom_action_bar/people_action_bar_widget.dart @@ -0,0 +1,86 @@ +import "package:flutter/material.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/selected_people.dart"; +import "package:photos/theme/ente_theme.dart"; + +class PeopleActionBarWidget extends StatefulWidget { + final SelectedPeople? selectedPeople; + final VoidCallback? onCancel; + const PeopleActionBarWidget({ + super.key, + this.selectedPeople, + this.onCancel, + }); + + @override + State createState() => _PeopleActionBarWidgetState(); +} + +class _PeopleActionBarWidgetState extends State { + final ValueNotifier _selectedPeopleNotifier = ValueNotifier(0); + + @override + void initState() { + widget.selectedPeople?.addListener(_selectedPeopleListener); + super.initState(); + } + + @override + void dispose() { + widget.selectedPeople?.removeListener(_selectedPeopleListener); + _selectedPeopleNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + return SizedBox( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 1, + child: ValueListenableBuilder( + valueListenable: _selectedPeopleNotifier, + builder: (context, value, child) { + final count = widget.selectedPeople?.personIds.length ?? 0; + return Text( + S.of(context).selectedPhotos(count), + style: textTheme.miniMuted, + ); + }, + ), + ), + Flexible( + flex: 1, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + widget.onCancel?.call(); + }, + child: Align( + alignment: Alignment.centerRight, + child: Text( + S.of(context).cancel, + style: textTheme.mini, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + void _selectedPeopleListener() { + _selectedPeopleNotifier.value = + widget.selectedPeople?.personIds.length ?? 0; + } +} diff --git a/mobile/lib/ui/components/bottom_action_bar/people_bottom_action_bar_widget.dart b/mobile/lib/ui/components/bottom_action_bar/people_bottom_action_bar_widget.dart new file mode 100644 index 0000000000..db73e07a9f --- /dev/null +++ b/mobile/lib/ui/components/bottom_action_bar/people_bottom_action_bar_widget.dart @@ -0,0 +1,57 @@ +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/models/selected_people.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/components/bottom_action_bar/people_action_bar_widget.dart"; +import "package:photos/ui/components/divider_widget.dart"; +import "package:photos/ui/viewer/actions/people_selection_action_widget.dart"; + +class PeopleBottomActionBarWidget extends StatelessWidget { + final SelectedPeople selectedPeople; + final VoidCallback? onCancel; + final Color? backgroundColor; + + const PeopleBottomActionBarWidget( + this.selectedPeople, { + super.key, + this.backgroundColor, + this.onCancel, + }); + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.paddingOf(context).bottom; + final widthOfScreen = MediaQuery.sizeOf(context).width; + final colorScheme = getEnteColorScheme(context); + final double leftRightPadding = widthOfScreen > restrictedMaxWidth + ? (widthOfScreen - restrictedMaxWidth) / 2 + : 0; + return Container( + decoration: BoxDecoration( + color: backgroundColor ?? colorScheme.backgroundElevated2, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + padding: EdgeInsets.only( + top: 4, + bottom: bottomPadding, + right: leftRightPadding, + left: leftRightPadding, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + PeopleSelectionActionWidget(selectedPeople), + const DividerWidget(dividerType: DividerType.bottomBar), + PeopleActionBarWidget( + selectedPeople: selectedPeople, + onCancel: onCancel, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/viewer/actions/people_selection_action_widget.dart b/mobile/lib/ui/viewer/actions/people_selection_action_widget.dart new file mode 100644 index 0000000000..8249b61296 --- /dev/null +++ b/mobile/lib/ui/viewer/actions/people_selection_action_widget.dart @@ -0,0 +1,274 @@ +import "package:flutter/material.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/people_changed_event.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/ml/face/person.dart"; +import "package:photos/models/selected_people.dart"; +import "package:photos/services/machine_learning/face_ml/feedback/cluster_feedback.dart"; +import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; +import "package:photos/ui/components/bottom_action_bar/selection_action_button_widget.dart"; +import "package:photos/ui/viewer/people/person_cluster_suggestion.dart"; +import "package:photos/ui/viewer/people/save_or_edit_person.dart"; +import "package:photos/utils/dialog_util.dart"; +import "package:photos/utils/navigation_util.dart"; + +class PeopleSelectionActionWidget extends StatefulWidget { + final SelectedPeople selectedPeople; + + const PeopleSelectionActionWidget( + this.selectedPeople, { + super.key, + }); + + @override + State createState() => + _PeopleSelectionActionWidgetState(); +} + +class _PeopleSelectionActionWidgetState + extends State { + late Future> personEntitiesMapFuture; + final _logger = Logger("PeopleSelectionActionWidget"); + + @override + void initState() { + super.initState(); + widget.selectedPeople.addListener(_selectionChangedListener); + personEntitiesMapFuture = PersonService.instance.getPersonsMap(); + } + + @override + void dispose() { + widget.selectedPeople.removeListener(_selectionChangedListener); + super.dispose(); + } + + List _getSelectedPersonIds() { + return widget.selectedPeople.personIds + .where((id) => !id.startsWith('cluster_')) + .toList(); + } + + List _getSelectedClusterIds() { + return widget.selectedPeople.personIds + .where((id) => id.startsWith('cluster_')) + .toList(); + } + + void _selectionChangedListener() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + if (widget.selectedPeople.personIds.isEmpty) { + return const SizedBox.shrink(); + } + + final List items = []; + final selectedPersonIds = _getSelectedPersonIds(); + final selectedClusterIds = _getSelectedClusterIds(); + final onlyOnePerson = + selectedPersonIds.length == 1 && selectedClusterIds.isEmpty; + final onePersonAndClusters = + selectedPersonIds.length == 1 && selectedClusterIds.isNotEmpty; + final anythingSelected = + selectedPersonIds.isNotEmpty || selectedClusterIds.isNotEmpty; + + items.add( + SelectionActionButton( + labelText: S.of(context).edit, + icon: Icons.edit_outlined, + onTap: _onEditPerson, + shouldShow: onlyOnePerson, + ), + ); + items.add( + SelectionActionButton( + labelText: S.of(context).review, + icon: Icons.search_outlined, + onTap: _onReviewSuggestion, + shouldShow: onlyOnePerson, + ), + ); + items.add( + SelectionActionButton( + labelText: "Ignore", + icon: Icons.hide_image_outlined, + onTap: _onIgnore, + shouldShow: anythingSelected, + ), + ); + items.add( + SelectionActionButton( + labelText: "Merge", + icon: Icons.merge_outlined, + onTap: _onMerge, + shouldShow: onePersonAndClusters, + ), + ); + items.add( + SelectionActionButton( + labelText: "Reset", + icon: Icons.remove_outlined, + onTap: _onResetPerson, + shouldShow: onlyOnePerson, + ), + ); + + return MediaQuery( + data: MediaQuery.of(context).removePadding(removeBottom: true), + child: SafeArea( + child: Scrollbar( + radius: const Radius.circular(1), + thickness: 2, + thumbVisibility: true, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics( + decelerationRate: ScrollDecelerationRate.fast, + ), + scrollDirection: Axis.horizontal, + child: Container( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 4), + ...items, + const SizedBox(width: 4), + ], + ), + ), + ), + ), + ), + ); + } + + Future _onEditPerson() async { + final selectedPersonIds = _getSelectedPersonIds(); + if (selectedPersonIds.length != 1) return; + final personID = selectedPersonIds.first; + final personMap = await personEntitiesMapFuture; + final person = personMap[personID]; + if (person == null) return; + + await routeToPage( + context, + SaveOrEditPerson( + person.data.assigned.first.id, + person: person, + isEditing: true, + ), + ); + widget.selectedPeople.clearAll(); + } + + Future _onReviewSuggestion() async { + final selectedPersonIds = _getSelectedPersonIds(); + if (selectedPersonIds.length != 1) return; + final personID = selectedPersonIds.first; + final personMap = await personEntitiesMapFuture; + final person = personMap[personID]; + if (person == null) return; + + await routeToPage( + context, + PersonReviewClusterSuggestion(person), + ); + widget.selectedPeople.clearAll(); + } + + Future _onResetPerson() async { + final selectedPersonIds = _getSelectedPersonIds(); + if (selectedPersonIds.length != 1) return; + final personID = selectedPersonIds.first; + final personMap = await personEntitiesMapFuture; + final person = personMap[personID]; + if (person == null) return; + + await showChoiceDialog( + context, + title: S.of(context).areYouSureYouWantToResetThisPerson, + body: S.of(context).allPersonGroupingWillReset, + firstButtonLabel: S.of(context).yesResetPerson, + firstButtonOnTap: () async { + try { + await PersonService.instance.deletePerson(person.remoteID); + widget.selectedPeople.clearAll(); + } on Exception catch (e, s) { + _logger.severe('Failed to delete person', e, s); + } + }, + ); + } + + Future _onIgnore() async { + final selectedPersonIds = _getSelectedPersonIds(); + final selectedClusterIds = _getSelectedClusterIds(); + if (selectedPersonIds.isEmpty && selectedClusterIds.isEmpty) return; + + await showChoiceDialog( + context, + title: "Are you sure you want to ignore these persons?", + body: + "The person groups will not be displayed in the people section anymore. Photos will remain untouched.", + firstButtonLabel: "Yes, confirm", + firstButtonOnTap: () async { + try { + for (final clusterID in selectedClusterIds) { + await ClusterFeedbackService.instance.ignoreCluster(clusterID); + } + final personMap = await personEntitiesMapFuture; + for (final personID in selectedPersonIds) { + final person = personMap[personID]; + if (person == null) continue; + final ignoredPerson = person.copyWith( + data: person.data.copyWith(name: "", isHidden: true), + ); + await PersonService.instance.updatePerson(ignoredPerson); + } + Bus.instance.fire(PeopleChangedEvent()); + widget.selectedPeople.clearAll(); + } catch (e, s) { + _logger.severe('Ignoring a cluster failed', e, s); + } + }, + ); + } + + Future _onMerge() async { + final selectedPersonIds = _getSelectedPersonIds(); + final selectedClusterIds = _getSelectedClusterIds(); + if (selectedPersonIds.length != 1 || selectedClusterIds.isEmpty) return; + + await showChoiceDialog( + context, + title: "Are you sure you want to merge them?", + body: + "All unnamed groups will be merged into the selected person. This can still be undone from the suggestions history overview of the person", + firstButtonLabel: "Yes, confirm", + firstButtonOnTap: () async { + try { + final personMap = await personEntitiesMapFuture; + final personID = selectedPersonIds.first; + final person = personMap[personID]; + if (person == null) return; + for (final clusterID in selectedClusterIds) { + await ClusterFeedbackService.instance.addClusterToExistingPerson( + clusterID: clusterID, + person: person, + ); + } + Bus.instance.fire(PeopleChangedEvent()); + widget.selectedPeople.clearAll(); + } catch (e, s) { + _logger.severe('Merging clusters failed', e, s); + } + }, + ); + } +} diff --git a/mobile/lib/ui/viewer/search/result/people_section_all_page.dart b/mobile/lib/ui/viewer/search/result/people_section_all_page.dart index a28e3d12ed..a1294a37dc 100644 --- a/mobile/lib/ui/viewer/search/result/people_section_all_page.dart +++ b/mobile/lib/ui/viewer/search/result/people_section_all_page.dart @@ -3,29 +3,314 @@ import "dart:async"; import 'package:flutter/material.dart'; import "package:photos/events/event.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/models/search/generic_search_result.dart"; +import "package:photos/models/search/recent_searches.dart"; import "package:photos/models/search/search_constants.dart"; +import "package:photos/models/search/search_result.dart"; import "package:photos/models/search/search_types.dart"; import "package:photos/models/selected_people.dart"; import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart"; import "package:photos/services/search_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/components/bottom_action_bar/people_bottom_action_bar_widget.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/people/add_person_action_sheet.dart"; +import "package:photos/ui/viewer/people/people_page.dart"; +import "package:photos/ui/viewer/people/person_face_widget.dart"; +import "package:photos/ui/viewer/search/result/search_result_page.dart"; import "package:photos/ui/viewer/search_tab/people_section.dart"; +import "package:photos/utils/navigation_util.dart"; -class PeopleSectionAllPage extends StatelessWidget { +class PeopleSectionAllPage extends StatefulWidget { const PeopleSectionAllPage({ super.key, }); + @override + State createState() => _PeopleSectionAllPageState(); +} + +class _PeopleSectionAllPageState extends State { + final _selectedPeople = SelectedPeople(); + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(SectionType.face.sectionTitle(context)), - centerTitle: false, - ), - body: const PeopleSectionAllWidget(), + return ListenableBuilder( + listenable: _selectedPeople, + builder: (context, _) { + final hasSelection = _selectedPeople.personIds.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: Text(SectionType.face.sectionTitle(context)), + centerTitle: false, + ), + body: PeopleSectionAllSelectionWrapper( + selectedPeople: _selectedPeople, + ), + bottomNavigationBar: hasSelection + ? PeopleBottomActionBarWidget( + _selectedPeople, + onCancel: () { + _selectedPeople.clearAll(); + }, + ) + : null, + ); + }, + ); + } +} + +class PeopleSectionAllSelectionWrapper extends StatefulWidget { + final SelectedPeople selectedPeople; + + const PeopleSectionAllSelectionWrapper({ + super.key, + required this.selectedPeople, + }); + + @override + State createState() => + _PeopleSectionAllSelectionWrapperState(); +} + +class _PeopleSectionAllSelectionWrapperState + extends State { + @override + Widget build(BuildContext context) { + return PeopleSectionAllWidget( + selectedPeople: widget.selectedPeople, + ); + } +} + +class SelectablePersonSearchExample extends StatelessWidget { + final GenericSearchResult searchResult; + final double size; + final SelectedPeople selectedPeople; + + const SelectablePersonSearchExample({ + super.key, + required this.searchResult, + required this.selectedPeople, + this.size = 102, + }); + + void _handleTap(BuildContext context) { + if (selectedPeople.personIds.isNotEmpty) { + _toggleSelection(); + } else { + _handleNavigation(context); + } + } + + void _handleLongPress() { + _toggleSelection(); + } + + void _toggleSelection() { + final personId = searchResult.params[kPersonParamID] as String?; + final clusterId = searchResult.params[kClusterParamId] as String?; + + final idToUse = + (personId != null && personId.isNotEmpty) ? personId : clusterId; + + if (idToUse != null && idToUse.isNotEmpty) { + selectedPeople.toggleSelection(idToUse); + } + } + + void _handleNavigation(BuildContext context) { + RecentSearches().add(searchResult.name()); + if (searchResult.onResultTap != null) { + searchResult.onResultTap!(context); + } else { + routeToPage( + context, + SearchResultPage(searchResult), + ); + } + } + + @override + Widget build(BuildContext context) { + final borderRadius = 82 * (size / 102); + final bool isCluster = (searchResult.type() == ResultType.faces && + int.tryParse(searchResult.name()) != null); + + return ListenableBuilder( + listenable: selectedPeople, + builder: (context, _) { + final personId = searchResult.params[kPersonParamID] as String?; + final clusterId = searchResult.params[kClusterParamId] as String?; + final idToCheck = + (personId != null && personId.isNotEmpty) ? personId : clusterId; + final bool isSelected = idToCheck != null + ? selectedPeople.isPersonSelected(idToCheck) + : false; + + return GestureDetector( + onTap: () => _handleTap(context), + onLongPress: _handleLongPress, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + ClipPath( + clipper: ShapeBorderClipper( + shape: ContinuousRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: getEnteColorScheme(context).strokeFaint, + ), + ), + ), + Builder( + builder: (context) { + late Widget child; + + if (searchResult.previewThumbnail() != null) { + child = searchResult.type() != ResultType.faces + ? ThumbnailWidget( + searchResult.previewThumbnail()!, + shouldShowSyncStatus: false, + ) + : FaceSearchResult(searchResult); + } else { + child = const NoThumbnailWidget( + addBorder: false, + ); + } + return SizedBox( + width: size - 2, + height: size - 2, + child: ClipPath( + clipper: ShapeBorderClipper( + shape: ContinuousRectangleBorder( + borderRadius: + searchResult.previewThumbnail() != null + ? BorderRadius.circular(borderRadius - 1) + : BorderRadius.circular(81), + ), + ), + child: ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity( + isSelected ? 0.4 : 0, + ), + BlendMode.darken, + ), + child: child, + ), + ), + ); + }, + ), + Positioned( + top: 5, + right: 5, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: isSelected + ? const Icon( + Icons.check_circle_rounded, + color: Colors.white, + size: 22, + ) + : null, + ), + ), + ], + ), + isCluster + ? GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + final result = await showAssignPersonAction( + context, + clusterID: searchResult.name(), + ); + if (result != null && + result is (PersonEntity, EnteFile)) { + // ignore: unawaited_futures + routeToPage( + context, + PeoplePage( + person: result.$1, + searchResult: null, + ), + ); + } else if (result != null && result is PersonEntity) { + // ignore: unawaited_futures + routeToPage( + context, + PeoplePage( + person: result, + searchResult: null, + ), + ); + } + }, + child: Padding( + padding: const EdgeInsets.only(top: 6, bottom: 0), + child: Text( + "Add name", + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: getEnteTextTheme(context).small, + ), + ), + ) + : Padding( + padding: const EdgeInsets.only(top: 6, bottom: 0), + child: Text( + searchResult.name(), + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: getEnteTextTheme(context).small, + ), + ), + ], + ), + ); + }, + ); + } +} + +class FaceSearchResult extends StatelessWidget { + final SearchResult searchResult; + + const FaceSearchResult(this.searchResult, {super.key}); + + @override + Widget build(BuildContext context) { + final params = (searchResult as GenericSearchResult).params; + return PersonFaceWidget( + personId: params[kPersonParamID], + clusterID: params[kClusterParamId], + key: params.containsKey(kPersonWidgetKey) + ? ValueKey(params[kPersonWidgetKey]) + : null, ); } } @@ -163,11 +448,17 @@ class _PeopleSectionAllWidgetState extends State { delegate: SliverChildBuilderDelegate( childCount: normalFaces.length, (context, index) { - return PersonSearchExample( - searchResult: normalFaces[index], - size: itemSize, - selectedPeople: widget.selectedPeople, - ); + return !widget.namedOnly + ? SelectablePersonSearchExample( + searchResult: normalFaces[index], + size: itemSize, + selectedPeople: widget.selectedPeople!, + ) + : PersonSearchExample( + searchResult: normalFaces[index], + size: itemSize, + selectedPeople: widget.selectedPeople!, + ); }, ), ), @@ -196,11 +487,17 @@ class _PeopleSectionAllWidgetState extends State { delegate: SliverChildBuilderDelegate( childCount: extraFaces.length, (context, index) { - return PersonSearchExample( - searchResult: extraFaces[index], - size: itemSize, - selectedPeople: widget.selectedPeople, - ); + return !widget.namedOnly + ? SelectablePersonSearchExample( + searchResult: extraFaces[index], + size: itemSize, + selectedPeople: widget.selectedPeople!, + ) + : PersonSearchExample( + searchResult: extraFaces[index], + size: itemSize, + selectedPeople: widget.selectedPeople!, + ); }, ), ),