person gallery suggestion UI

This commit is contained in:
laurenspriem
2025-06-17 11:36:31 +05:30
parent de079ca3b8
commit 3cd5610b45
2 changed files with 465 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ import "package:photos/ui/viewer/people/link_email_screen.dart";
import "package:photos/ui/viewer/people/people_app_bar.dart";
import "package:photos/ui/viewer/people/people_banner.dart";
import "package:photos/ui/viewer/people/person_cluster_suggestion.dart";
import "package:photos/ui/viewer/people/person_gallery_suggestion.dart";
import "package:photos/utils/navigation_util.dart";
class PeoplePage extends StatefulWidget {
@@ -303,7 +304,8 @@ class _Gallery extends StatelessWidget {
tagPrefix: tagPrefix + tagPrefix,
selectedFiles: selectedFiles,
initialFiles: personFiles.isNotEmpty ? [personFiles.first] : [],
header:
header: Column(
children: [
personEntity.data.email != null && personEntity.data.email!.isNotEmpty
? const SizedBox.shrink()
: Padding(
@@ -320,6 +322,11 @@ class _Gallery extends StatelessWidget {
},
),
),
PersonGallerySuggestion(
person: personEntity,
),
],
),
);
}
}

View File

@@ -0,0 +1,457 @@
import "dart:async";
import "dart:typed_data";
import 'package:flutter/material.dart';
import "package:logging/logging.dart";
import "package:photos/db/ml/db.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/theme/ente_theme.dart";
import "package:photos/ui/viewer/people/cluster_page.dart";
import "package:photos/ui/viewer/people/file_face_widget.dart";
import "package:photos/utils/face/face_thumbnail_cache.dart";
final _logger = Logger("PersonGallerySuggestion");
class PersonGallerySuggestion extends StatefulWidget {
final PersonEntity person;
final VoidCallback? onSuggestionProcessed;
const PersonGallerySuggestion({
required this.person,
this.onSuggestionProcessed,
super.key,
});
@override
State<PersonGallerySuggestion> createState() =>
_PersonGallerySuggestionState();
}
class _PersonGallerySuggestionState extends State<PersonGallerySuggestion>
with TickerProviderStateMixin {
List<ClusterSuggestion> allSuggestions = [];
int currentSuggestionIndex = 0;
Map<int, Uint8List?> faceCrops = {};
bool isLoading = true;
bool isProcessing = false;
Map<int, Map<int, Uint8List?>> precomputedFaceCrops = {};
late AnimationController _slideController;
late AnimationController _fadeController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_initializeAnimations();
_loadInitialSuggestion();
}
void _initializeAnimations() {
_slideController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_fadeController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, -1.0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
),
);
}
Future<void> _loadInitialSuggestion() async {
try {
final suggestions = await ClusterFeedbackService.instance
.getFastSuggestionForPerson(widget.person);
if (suggestions.isNotEmpty && mounted) {
allSuggestions = suggestions;
currentSuggestionIndex = 0;
final crops = await _generateFaceThumbnails(
allSuggestions[0].filesInCluster.take(4).toList(),
allSuggestions[0].clusterIDToMerge,
);
setState(() {
faceCrops = crops;
isLoading = false;
});
// Start animations
unawaited(_fadeController.forward());
unawaited(_slideController.forward());
unawaited(_precomputeNextSuggestions());
} else {
setState(() {
isLoading = false;
});
}
} catch (e, s) {
_logger.severe("Error loading suggestion", e, s);
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> _precomputeNextSuggestions() async {
try {
// Precompute face crops for next few suggestions
const maxPrecompute = 3;
final endIndex = (currentSuggestionIndex + maxPrecompute)
.clamp(0, allSuggestions.length);
for (int i = currentSuggestionIndex + 1; i < endIndex; i++) {
if (!mounted) break;
final suggestion = allSuggestions[i];
final crops = await _generateFaceThumbnails(
suggestion.filesInCluster.take(4).toList(),
suggestion.clusterIDToMerge,
);
if (mounted) {
precomputedFaceCrops[i] = crops;
}
}
} catch (e, s) {
_logger.severe("Error precomputing next suggestions", e, s);
}
}
Future<Map<int, Uint8List?>> _generateFaceThumbnails(
List<EnteFile> files,
String clusterID,
) async {
final futures = <Future<Uint8List?>>[];
for (final file in files) {
futures.add(
precomputeClusterFaceCrop(
file,
clusterID,
useFullFile: true,
),
);
}
final faceCropsList = await Future.wait(futures);
final faceCrops = <int, Uint8List?>{};
for (var i = 0; i < faceCropsList.length; i++) {
faceCrops[files[i].uploadedFileID!] = faceCropsList[i];
}
return faceCrops;
}
void _navigateToCluster() {
if (allSuggestions.isEmpty ||
currentSuggestionIndex >= allSuggestions.length) return;
final currentSuggestion = allSuggestions[currentSuggestionIndex];
final List<EnteFile> sortedFiles = List<EnteFile>.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: currentSuggestion.clusterIDToMerge,
showNamingBanner: false,
),
),
);
}
Future<void> _handleUserChoice(bool accepted) async {
if (isProcessing ||
allSuggestions.isEmpty ||
currentSuggestionIndex >= allSuggestions.length) return;
setState(() {
isProcessing = true;
});
try {
final currentSuggestion = allSuggestions[currentSuggestionIndex];
if (accepted) {
await ClusterFeedbackService.instance.addClusterToExistingPerson(
person: widget.person,
clusterID: currentSuggestion.clusterIDToMerge,
);
} else {
await MLDataDB.instance.captureNotPersonFeedback(
personID: widget.person.remoteID,
clusterID: currentSuggestion.clusterIDToMerge,
);
}
// Animate out current suggestion first
await _animateOut();
// Move to next suggestion
currentSuggestionIndex++;
// Check if we have more suggestions
if (currentSuggestionIndex < allSuggestions.length) {
// Get face crops for next suggestion (from precomputed or generate new)
Map<int, Uint8List?> nextCrops;
if (precomputedFaceCrops.containsKey(currentSuggestionIndex)) {
nextCrops = precomputedFaceCrops[currentSuggestionIndex]!;
} else {
final nextSuggestion = allSuggestions[currentSuggestionIndex];
nextCrops = await _generateFaceThumbnails(
nextSuggestion.filesInCluster.take(4).toList(),
nextSuggestion.clusterIDToMerge,
);
}
setState(() {
faceCrops = nextCrops;
isProcessing = false;
});
await Future.delayed(const Duration(milliseconds: 50));
unawaited(_animateIn());
// Continue precomputing future suggestions
unawaited(_precomputeNextSuggestions());
} else {
setState(() {
isProcessing = false;
});
}
widget.onSuggestionProcessed?.call();
} catch (e, s) {
_logger.severe("Error handling user choice", e, s);
if (mounted) {
setState(() {
isProcessing = false;
});
}
}
}
Future<void> _animateOut() async {
await Future.wait([
_fadeController.reverse(),
_slideController.reverse(),
]);
}
Future<void> _animateIn() async {
_slideController.reset();
_fadeController.reset();
await Future.wait([
_fadeController.forward(),
_slideController.forward(),
]);
}
@override
void dispose() {
_slideController.dispose();
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (isLoading ||
allSuggestions.isEmpty ||
currentSuggestionIndex >= allSuggestions.length) {
return const SizedBox.shrink();
}
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: GestureDetector(
key: ValueKey('suggestion_$currentSuggestionIndex'),
onTap: _navigateToCluster,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.fillFaint,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorScheme.strokeFainter,
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
Text(
"Are they ${widget.person.data.name}?",
style: textTheme.bodyBold,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Face thumbnails
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _buildFaceThumbnails(),
),
const SizedBox(height: 16),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// No button
GestureDetector(
onTap:
isProcessing ? null : () => _handleUserChoice(false),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.fillMuted,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(
Icons.close,
color: colorScheme.warning700,
size: 24,
),
const SizedBox(height: 4),
Text(
"No",
style: textTheme.miniMuted,
),
],
),
),
),
// Yes button
GestureDetector(
onTap:
isProcessing ? null : () => _handleUserChoice(true),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.fillMuted,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(
Icons.check,
color: colorScheme.primary700,
size: 24,
),
const SizedBox(height: 4),
Text(
"Yes",
style: textTheme.miniMuted,
),
],
),
),
),
],
),
],
),
),
),
),
);
}
List<Widget> _buildFaceThumbnails() {
final currentSuggestion = allSuggestions[currentSuggestionIndex];
final files = currentSuggestion.filesInCluster.take(4).toList();
final thumbnails = <Widget>[];
for (int i = 0; i < files.length; i++) {
final file = files[i];
final faceCrop = faceCrops[file.uploadedFileID!];
if (i > 0) {
thumbnails.add(const SizedBox(width: 8));
}
thumbnails.add(
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
border: Border.all(
color: getEnteColorScheme(context).strokeFainter,
width: 1,
),
),
child: ClipRRect(
child: ClipPath(
clipper: ShapeBorderClipper(
shape: ContinuousRectangleBorder(
borderRadius: BorderRadius.circular(52),
),
),
child: FileFaceWidget(
key: ValueKey(
'face_${currentSuggestionIndex}_${file.uploadedFileID}',
),
file,
faceCrop: faceCrop,
clusterID: currentSuggestion.clusterIDToMerge,
useFullFile: true,
thumbnailFallback: true,
),
),
),
),
);
}
return thumbnails;
}
}