import "dart:async" show StreamSubscription, unawaited; import "dart:math"; import "dart:typed_data"; import "package:flutter/foundation.dart" show kDebugMode; import "package:flutter/material.dart"; import "package:logging/logging.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/db/ml/db.dart"; import "package:photos/events/people_changed_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/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/feedback/cluster_feedback.dart'; import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; import "package:photos/theme/ente_theme.dart"; import "package:photos/ui/components/buttons/button_widget.dart"; import "package:photos/ui/components/models/button_type.dart"; import "package:photos/ui/viewer/people/cluster_page.dart"; import "package:photos/ui/viewer/people/person_clusters_page.dart"; import "package:photos/ui/viewer/search/result/person_face_widget.dart"; import "package:photos/utils/face/face_box_crop.dart"; class SuggestionUserFeedback { final bool accepted; final ClusterSuggestion suggestion; SuggestionUserFeedback(this.accepted, this.suggestion); } class PersonReviewClusterSuggestion extends StatefulWidget { final PersonEntity person; const PersonReviewClusterSuggestion( this.person, { super.key, }); @override State createState() => _PersonClustersState(); } class _PersonClustersState extends State { int currentSuggestionIndex = 0; Key futureBuilderKeySuggestions = UniqueKey(); Key futureBuilderKeyFaceThumbnails = UniqueKey(); bool canGiveFeedback = true; List pastUserFeedback = []; List allSuggestions = []; late final Logger _logger = Logger('_PersonClustersState'); late final mlDataDB = MLDataDB.instance; // Declare a variable for the future late Future> futureClusterSuggestions; late StreamSubscription _peopleChangedEvent; @override void initState() { super.initState(); // Initialize the future in initState _fetchClusterSuggestions(); } @override void dispose() { _peopleChangedEvent.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(context.l10n.reviewSuggestions), actions: [ if (pastUserFeedback.isNotEmpty) IconButton( icon: const Icon(Icons.undo_outlined), onPressed: () async { await _undoLastFeedback(); }, ), IconButton( icon: const Icon(Icons.history_outlined), onPressed: () { Navigator.of(context).push( MaterialPageRoute( builder: (context) => PersonClustersPage(widget.person), ), ); }, ), ], ), body: FutureBuilder>( key: futureBuilderKeySuggestions, future: futureClusterSuggestions, builder: (context, snapshot) { if (snapshot.hasData) { if (snapshot.data!.isEmpty) { return Center( child: Text( S.of(context).noSuggestionsForPerson(widget.person.data.name), style: getEnteTextTheme(context).largeMuted, ), ); } allSuggestions = snapshot.data!; final numberOfDifferentSuggestions = allSuggestions.length; final currentSuggestion = allSuggestions[currentSuggestionIndex]; final String clusterID = currentSuggestion.clusterIDToMerge; final double distance = currentSuggestion.distancePersonToCluster; final bool usingMean = currentSuggestion.usedOnlyMeanForSuggestion; final List files = currentSuggestion.filesInCluster; final Future> generateFacedThumbnails = _generateFaceThumbnails( files.sublist(0, min(files.length, 6)), clusterID, ); _peopleChangedEvent = Bus.instance.on().listen((event) { if (event.type == PeopleEventType.removedFilesFromCluster && (event.source == clusterID.toString())) { for (var updatedFile in event.relevantFiles!) { files.remove(updatedFile); } setState(() {}); } }); return Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: SingleChildScrollView( child: GestureDetector( onTap: () { final List sortedFiles = List.from( currentSuggestion.filesInCluster, ); sortedFiles.sort( (a, b) => b.creationTime!.compareTo(a.creationTime!), ); Navigator.of(context).push( MaterialPageRoute( builder: (context) => ClusterPage( sortedFiles, personID: widget.person, clusterID: clusterID, showNamingBanner: false, ), ), ); }, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 20, ), child: _buildSuggestionView( clusterID, distance, usingMean, files, numberOfDifferentSuggestions, generateFacedThumbnails, ), ), ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.only( left: 12.0, right: 12.0, bottom: 48, ), child: Row( children: [ Expanded( child: ButtonWidget( buttonType: ButtonType.tertiaryCritical, icon: Icons.close, labelText: context.l10n.no, buttonSize: ButtonSize.large, onTap: () async => { await _handleUserClusterChoice( clusterID, false, numberOfDifferentSuggestions, ), }, ), ), const SizedBox(width: 12.0), Expanded( child: ButtonWidget( buttonType: ButtonType.primary, labelText: context.l10n.yes, buttonSize: ButtonSize.large, onTap: () async => { await _handleUserClusterChoice( clusterID, true, numberOfDifferentSuggestions, ), }, ), ), ], ), ), ), ], ); } else if (snapshot.hasError) { _logger.severe( "Error fetching suggestions", snapshot.error!, snapshot.stackTrace, ); return const Center(child: Text("Error")); } else { return const Center(child: CircularProgressIndicator()); } }, ), ); } Future _handleUserClusterChoice( String clusterID, bool yesOrNo, int numberOfSuggestions, ) async { // Perform the action based on clusterID, e.g., assignClusterToPerson or captureNotPersonFeedback if (!canGiveFeedback) { return; } // Store the feedback in case the user wants to revert pastUserFeedback.add( SuggestionUserFeedback( yesOrNo, allSuggestions[currentSuggestionIndex], ), ); if (yesOrNo) { canGiveFeedback = false; await ClusterFeedbackService.instance.addClusterToExistingPerson( person: widget.person, clusterID: clusterID, ); // Increment the suggestion index if (mounted) { setState(() => currentSuggestionIndex++); } // Check if we need to fetch new data if (currentSuggestionIndex >= (numberOfSuggestions)) { setState(() { currentSuggestionIndex = 0; futureBuilderKeySuggestions = UniqueKey(); // Reset to trigger FutureBuilder futureBuilderKeyFaceThumbnails = UniqueKey(); _fetchClusterSuggestions(); }); } else { futureBuilderKeyFaceThumbnails = UniqueKey(); setState(() {}); } } else { await _rejectSuggestion(clusterID, numberOfSuggestions); } } Future _rejectSuggestion( String clusterID, int numberOfSuggestions, ) async { canGiveFeedback = false; await mlDataDB.captureNotPersonFeedback( personID: widget.person.remoteID, clusterID: clusterID, ); // Recalculate the suggestions when a suggestion is rejected setState(() { currentSuggestionIndex = 0; futureBuilderKeySuggestions = UniqueKey(); // Reset to trigger FutureBuilder futureBuilderKeyFaceThumbnails = UniqueKey(); _fetchClusterSuggestions(); }); } // Method to fetch cluster suggestions void _fetchClusterSuggestions() { debugPrint("Fetching suggestions for ${widget.person.data.name}"); futureClusterSuggestions = ClusterFeedbackService.instance.getSuggestionForPerson(widget.person); } Widget _buildSuggestionView( String clusterID, double distance, bool usingMean, List files, int numberOfSuggestions, Future> generateFaceThumbnails, ) { final widgetToReturn = Column( key: ValueKey("cluster_id-$clusterID-files-${files.length}"), children: [ if (kDebugMode) Text( "ClusterID: $clusterID, Distance: ${distance.toStringAsFixed(3)}, usingMean: $usingMean", style: getEnteTextTheme(context).smallMuted, ), Text( "${widget.person.data.name}?", style: getEnteTextTheme(context).largeMuted, ), const SizedBox(height: 24), _buildThumbnailWidget( files, clusterID, generateFaceThumbnails, ), const SizedBox( height: 24.0, ), ], ); // Precompute face thumbnails for next suggestions, in case there are precomputeFaceCrops(); return widgetToReturn; } Widget _buildThumbnailWidget( List files, String clusterID, Future> generateFaceThumbnails, ) { return FutureBuilder>( key: futureBuilderKeyFaceThumbnails, future: generateFaceThumbnails, builder: (context, snapshot) { if (snapshot.hasData) { final faceThumbnails = snapshot.data!; canGiveFeedback = true; return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: _buildThumbnailWidgetsRow( files, clusterID, faceThumbnails, ), ), if (files.length > 3) const SizedBox(height: 12), if (files.length > 3) Row( mainAxisAlignment: MainAxisAlignment.center, children: _buildThumbnailWidgetsRow( files, clusterID, faceThumbnails, start: 3, ), ), ], ); } else if (snapshot.hasError) { // log the error return Center(child: Text(S.of(context).error)); } else { canGiveFeedback = false; return const Center(child: CircularProgressIndicator()); } }, ); } List _buildThumbnailWidgetsRow( List files, String cluserId, Map faceThumbnails, { int start = 0, }) { return List.generate( min(3, max(0, files.length - start)), (index) => Padding( padding: const EdgeInsets.all(8.0), child: SizedBox( width: 100, height: 100, child: Stack( children: [ ClipPath( clipper: ShapeBorderClipper( shape: ContinuousRectangleBorder( borderRadius: BorderRadius.circular(75), ), ), child: PersonFaceWidget( files[start + index], clusterID: cluserId, useFullFile: true, thumbnailFallback: false, faceCrop: faceThumbnails[files[start + index].uploadedFileID!], ), ), if (start + index == 5 && files.length > 6) ClipPath( clipper: ShapeBorderClipper( shape: ContinuousRectangleBorder( borderRadius: BorderRadius.circular(72), ), ), child: Container( decoration: BoxDecoration( color: Colors.black.withOpacity(0.7), ), child: Center( child: Text( '+${files.length - 5}', style: darkTheme.textTheme.h3Bold, ), ), ), ), ], ), ), ), ); } Future> _generateFaceThumbnails( List files, String clusterID, ) async { final futures = >[]; for (final file in files) { futures.add( precomputeClusterFaceCrop( file, clusterID, useFullFile: true, ), ); } final faceCropsList = await Future.wait(futures); final faceCrops = {}; for (var i = 0; i < faceCropsList.length; i++) { faceCrops[files[i].uploadedFileID!] = faceCropsList[i]; } return faceCrops; } void precomputeFaceCrops() { const precomputeSuggestions = 6; const maxPrecomputations = 8; int compCount = 0; if (allSuggestions.length > currentSuggestionIndex + 1) { outerLoop: for (final suggestion in allSuggestions.sublist( currentSuggestionIndex + 1, min( allSuggestions.length, currentSuggestionIndex + precomputeSuggestions, ), )) { final files = suggestion.filesInCluster; final clusterID = suggestion.clusterIDToMerge; final preComputesLeft = maxPrecomputations - compCount; final computeHere = min(files.length, min(preComputesLeft, 6)); unawaited( _generateFaceThumbnails(files.sublist(0, computeHere), clusterID), ); compCount += computeHere; if (compCount >= maxPrecomputations) { debugPrint( 'Prefetching $compCount face thumbnails for suggestions', ); break outerLoop; } } } } Future _undoLastFeedback() async { if (pastUserFeedback.isNotEmpty) { final SuggestionUserFeedback lastFeedback = pastUserFeedback.removeLast(); if (lastFeedback.accepted) { await PersonService.instance.removeClusterToPerson( personID: widget.person.remoteID, clusterID: lastFeedback.suggestion.clusterIDToMerge, ); } else { await mlDataDB.removeNotPersonFeedback( personID: widget.person.remoteID, clusterID: lastFeedback.suggestion.clusterIDToMerge, ); } futureClusterSuggestions = futureClusterSuggestions.then((list) { return list.sublist(currentSuggestionIndex) ..insert(0, lastFeedback.suggestion); }); currentSuggestionIndex = 0; futureBuilderKeySuggestions = UniqueKey(); futureBuilderKeyFaceThumbnails = UniqueKey(); setState(() {}); } } }