diff --git a/mobile/lib/ui/viewer/file_details/face_widget.dart b/mobile/lib/ui/viewer/file_details/face_widget.dart index a7045f92c2..42ae6b0a10 100644 --- a/mobile/lib/ui/viewer/file_details/face_widget.dart +++ b/mobile/lib/ui/viewer/file_details/face_widget.dart @@ -1,4 +1,5 @@ import "dart:developer" show log; +import "dart:io" show Platform; import "dart:typed_data"; import "package:flutter/material.dart"; @@ -9,6 +10,7 @@ import 'package:photos/models/file/file.dart'; import "package:photos/services/search_service.dart"; import "package:photos/ui/viewer/file/no_thumbnail_widget.dart"; import "package:photos/ui/viewer/people/cluster_page.dart"; +import "package:photos/ui/viewer/people/cropped_face_image_view.dart"; import "package:photos/ui/viewer/people/people_page.dart"; import "package:photos/utils/face/face_box_crop.dart"; import "package:photos/utils/thumbnail_util.dart"; @@ -29,11 +31,104 @@ class FaceWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder( - future: getFaceCrop(), - builder: (context, snapshot) { - if (snapshot.hasData) { - final ImageProvider imageProvider = MemoryImage(snapshot.data!); + if (Platform.isIOS) { + return FutureBuilder( + future: getFaceCrop(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final ImageProvider imageProvider = MemoryImage(snapshot.data!); + return GestureDetector( + onTap: () async { + log( + "FaceWidget is tapped, with person $person and clusterID $clusterID", + name: "FaceWidget", + ); + if (person == null && clusterID == null) { + return; + } + if (person != null) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PeoplePage( + person: person!, + ), + ), + ); + } else if (clusterID != null) { + final fileIdsToClusterIds = + await FaceMLDataDB.instance.getFileIdToClusterIds(); + final files = await SearchService.instance.getAllFiles(); + final clusterFiles = files + .where( + (file) => + fileIdsToClusterIds[file.uploadedFileID] + ?.contains(clusterID) ?? + false, + ) + .toList(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ClusterPage( + clusterFiles, + cluserID: clusterID!, + ), + ), + ); + } + }, + child: Column( + children: [ + ClipRRect( + borderRadius: + const BorderRadius.all(Radius.elliptical(16, 12)), + child: SizedBox( + width: 60, + height: 60, + child: Image( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 8), + if (person != null) + Text( + person!.attr.name.trim(), + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), + ); + } else { + if (snapshot.connectionState == ConnectionState.waiting) { + return const ClipRRect( + borderRadius: BorderRadius.all(Radius.elliptical(16, 12)), + child: SizedBox( + width: 60, // Ensure consistent sizing + height: 60, + child: CircularProgressIndicator(), + ), + ); + } + if (snapshot.hasError) { + log('Error getting face: ${snapshot.error}'); + } + return const ClipRRect( + borderRadius: BorderRadius.all(Radius.elliptical(16, 12)), + child: SizedBox( + width: 60, // Ensure consistent sizing + height: 60, + child: NoThumbnailWidget(), + ), + ); + } + }, + ); + } else { + return Builder( + builder: (context) { return GestureDetector( onTap: () async { log( @@ -81,9 +176,9 @@ class FaceWidget extends StatelessWidget { child: SizedBox( width: 60, height: 60, - child: Image( - image: imageProvider, - fit: BoxFit.cover, + child: CroppedFaceImageView( + enteFile: file, + face: face, ), ), ), @@ -98,31 +193,9 @@ class FaceWidget extends StatelessWidget { ], ), ); - } else { - if (snapshot.connectionState == ConnectionState.waiting) { - return const ClipRRect( - borderRadius: BorderRadius.all(Radius.elliptical(16, 12)), - child: SizedBox( - width: 60, // Ensure consistent sizing - height: 60, - child: CircularProgressIndicator(), - ), - ); - } - if (snapshot.hasError) { - log('Error getting face: ${snapshot.error}'); - } - return const ClipRRect( - borderRadius: BorderRadius.all(Radius.elliptical(16, 12)), - child: SizedBox( - width: 60, // Ensure consistent sizing - height: 60, - child: NoThumbnailWidget(), - ), - ); - } - }, - ); + }, + ); + } } Future getFaceCrop() async { diff --git a/mobile/lib/ui/viewer/people/cropped_face_image_view.dart b/mobile/lib/ui/viewer/people/cropped_face_image_view.dart new file mode 100644 index 0000000000..04980098fd --- /dev/null +++ b/mobile/lib/ui/viewer/people/cropped_face_image_view.dart @@ -0,0 +1,117 @@ +import 'dart:developer' show log; +import "dart:io" show File; + +import 'package:flutter/material.dart'; +import "package:photos/face/model/face.dart"; +import "package:photos/models/file/file.dart"; +import "package:photos/ui/viewer/file/thumbnail_widget.dart"; +import "package:photos/utils/file_util.dart"; + +class CroppedFaceInfo { + final Image image; + final double scale; + final double offsetX; + final double offsetY; + + const CroppedFaceInfo({ + required this.image, + required this.scale, + required this.offsetX, + required this.offsetY, + }); +} + +class CroppedFaceImageView extends StatelessWidget { + final EnteFile enteFile; + final Face face; + + const CroppedFaceImageView({ + Key? key, + required this.enteFile, + required this.face, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: getImage(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Image image = snapshot.data!; + + final double viewWidth = constraints.maxWidth; + final double viewHeight = constraints.maxHeight; + + final faceBox = face.detection.box; + + final double relativeFaceCenterX = + faceBox.xMin + faceBox.width / 2; + final double relativeFaceCenterY = + faceBox.yMin + faceBox.height / 2; + + const double desiredFaceHeightRelativeToWidget = 1 / 2; + final double scale = + (1 / faceBox.height) * desiredFaceHeightRelativeToWidget; + + final double widgetCenterX = viewWidth / 2; + final double widgetCenterY = viewHeight / 2; + + final double imageAspectRatio = enteFile.width / enteFile.height; + final double widgetAspectRatio = viewWidth / viewHeight; + final double imageToWidgetRatio = + imageAspectRatio / widgetAspectRatio; + + double offsetX = + (widgetCenterX - relativeFaceCenterX * viewWidth) * scale; + double offsetY = + (widgetCenterY - relativeFaceCenterY * viewHeight) * scale; + + if (imageAspectRatio > widgetAspectRatio) { + // Landscape Image: Adjust offsetX more conservatively + offsetX = offsetX * imageToWidgetRatio; + } else { + // Portrait Image: Adjust offsetY more conservatively + offsetY = offsetY / imageToWidgetRatio; + } + + return ClipRect( + clipBehavior: Clip.antiAlias, + child: Transform.translate( + offset: Offset( + offsetX, + offsetY, + ), + child: Transform.scale( + scale: scale, + child: image, + ), + ), + ); + }, + ); + } else { + if (snapshot.hasError) { + log('Error getting cover face for person: ${snapshot.error}'); + } + return ThumbnailWidget( + enteFile, + ); + } + }, + ); + } + + Future getImage() async { + final File? ioFile = await getFile(enteFile); + if (ioFile == null) { + return null; + } + + final imageData = await ioFile.readAsBytes(); + final image = Image.memory(imageData, fit: BoxFit.cover); + + return image; + } +}