From 0f6c9060bf5e186919a2078d456bdf11843f26b2 Mon Sep 17 00:00:00 2001 From: genki <123@1234.com> Date: Sun, 11 Jan 2026 21:06:38 -0500 Subject: [PATCH] Pre UI Sweep --- .idea/deviceManager.xml | 7 +- .../ui/presentation/AppDrawerContent.kt | 114 ++-- .../ui/trainingprep/FacePickerDialog.kt | 491 ++++++++++-------- 3 files changed, 326 insertions(+), 286 deletions(-) diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml index 91f9558..5df2b2d 100644 --- a/.idea/deviceManager.xml +++ b/.idea/deviceManager.xml @@ -5,9 +5,14 @@ + \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt index 9cd6331..c012ac4 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt @@ -18,8 +18,7 @@ import androidx.compose.material.icons.filled.* import com.placeholder.sherpai2.ui.navigation.AppRoutes /** - * Beautiful app drawer with sections, gradient header, and polish - * UPDATED: Tour → Explore + * SLIMMED DOWN AppDrawer - 280dp width, inline logo, cleaner sections */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -28,12 +27,12 @@ fun AppDrawerContent( onDestinationClicked: (String) -> Unit ) { ModalDrawerSheet( - modifier = Modifier.width(300.dp), + modifier = Modifier.width(280.dp), // SLIMMER (was 300dp) drawerContainerColor = MaterialTheme.colorScheme.surface ) { Column(modifier = Modifier.fillMaxSize()) { - // ===== BEAUTIFUL GRADIENT HEADER ===== + // ===== COMPACT HEADER - Icon + Text Inline ===== Box( modifier = Modifier .fillMaxWidth() @@ -45,15 +44,16 @@ fun AppDrawerContent( ) ) ) - .padding(24.dp) + .padding(20.dp) // Reduced padding ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - // App icon/logo area + // App icon - smaller Surface( - modifier = Modifier.size(56.dp), - shape = RoundedCornerShape(16.dp), + modifier = Modifier.size(48.dp), // Smaller (was 56dp) + shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.primary, shadowElevation = 4.dp ) { @@ -61,44 +61,47 @@ fun AppDrawerContent( Icon( Icons.Default.Face, contentDescription = null, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onPrimary ) } } - Text( - "SherpAI", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) + // Text next to icon + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "SherpAI", + style = MaterialTheme.typography.titleLarge, // Smaller (was headlineMedium) + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) - Text( - "Face Recognition System", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text( + "Face Recognition System", + style = MaterialTheme.typography.bodySmall, // Smaller + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // Reduced spacing // ===== NAVIGATION SECTIONS ===== Column( modifier = Modifier .fillMaxWidth() .weight(1f) - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + .padding(horizontal = 8.dp), // Reduced padding + verticalArrangement = Arrangement.spacedBy(2.dp) // Tighter spacing ) { // Photos Section DrawerSection(title = "Photos") val photoItems = listOf( - DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search, "Find photos by tag or person"), - DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore, "Browse smart albums") + DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search), + DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore) ) photoItems.forEach { item -> @@ -109,15 +112,15 @@ fun AppDrawerContent( ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // Face Recognition Section DrawerSection(title = "Face Recognition") val faceItems = listOf( - DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face, "Existing models"), - DrawerItem(AppRoutes.TRAIN, "Create Person", Icons.Default.ModelTraining, "New person model"), - DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy, "AI model management") + DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face), + DrawerItem(AppRoutes.TRAIN, "Train New", Icons.Default.ModelTraining), + DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy) ) faceItems.forEach { item -> @@ -128,14 +131,14 @@ fun AppDrawerContent( ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // Organization Section DrawerSection(title = "Organization") val orgItems = listOf( - DrawerItem(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label, "Manage Tags"), - DrawerItem(AppRoutes.UTILITIES, "Util.", Icons.Default.NewReleases, "Manage Collection") + DrawerItem(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label), + DrawerItem(AppRoutes.UTILITIES, "Utilities", Icons.Default.Build) ) orgItems.forEach { item -> @@ -150,7 +153,7 @@ fun AppDrawerContent( // Settings at bottom HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 6.dp), color = MaterialTheme.colorScheme.outlineVariant ) @@ -158,35 +161,34 @@ fun AppDrawerContent( item = DrawerItem( AppRoutes.SETTINGS, "Settings", - Icons.Default.Settings, - "App preferences" + Icons.Default.Settings ), selected = AppRoutes.SETTINGS == currentRoute, onClick = { onDestinationClicked(AppRoutes.SETTINGS) } ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) } } } } /** - * Section header in drawer + * Section header - more compact */ @Composable private fun DrawerSection(title: String) { Text( text = title, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, // Smaller fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) // Reduced padding ) } /** - * Individual navigation item with icon, label, and subtitle + * Navigation item - cleaner, no subtitle */ @Composable private fun DrawerNavigationItem( @@ -196,33 +198,24 @@ private fun DrawerNavigationItem( ) { NavigationDrawerItem( label = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = item.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal - ) - item.subtitle?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - } + Text( + text = item.label, + style = MaterialTheme.typography.bodyMedium, // Slightly smaller + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) }, icon = { Icon( item.icon, contentDescription = item.label, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) // Slightly smaller ) }, selected = selected, onClick = onClick, modifier = Modifier .padding(NavigationDrawerItemDefaults.ItemPadding) - .clip(RoundedCornerShape(12.dp)), + .clip(RoundedCornerShape(10.dp)), // Slightly smaller radius colors = NavigationDrawerItemDefaults.colors( selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedIconColor = MaterialTheme.colorScheme.primary, @@ -233,11 +226,10 @@ private fun DrawerNavigationItem( } /** - * Data class for drawer items + * Simplified drawer item (no subtitle) */ private data class DrawerItem( val route: String, val label: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val subtitle: String? = null + val icon: androidx.compose.ui.graphics.vector.ImageVector ) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt index 125e171..db10908 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt @@ -5,65 +5,99 @@ import android.graphics.BitmapFactory import android.graphics.Rect import android.net.Uri import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext +import java.io.InputStream /** * Dialog for selecting a face from multiple detected faces + * + * CRITICAL: Re-detects faces on full resolution bitmap to ensure accurate cropping. + * Face bounds from FaceDetectionHelper are from downsampled images and won't match + * the full resolution bitmap loaded here. */ @Composable fun FacePickerDialog( result: FaceDetectionHelper.FaceDetectionResult, onDismiss: () -> Unit, - onFaceSelected: (Int, Bitmap) -> Unit // faceIndex, croppedFaceBitmap + onFaceSelected: (Int, Bitmap) -> Unit ) { val context = LocalContext.current - var selectedFaceIndex by remember { mutableStateOf(null) } + var selectedFaceIndex by remember { mutableStateOf(0) } var croppedFaces by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } - // Load and crop all faces + // Load and crop all faces - RE-DETECT to get accurate bounds LaunchedEffect(result) { isLoading = true - croppedFaces = withContext(Dispatchers.IO) { - val bitmap = loadBitmapFromUri(context, result.uri) - bitmap?.let { bmp -> - result.faceBounds.map { bounds -> - cropFaceFromBitmap(bmp, bounds) + errorMessage = null + + try { + croppedFaces = withContext(Dispatchers.IO) { + // Load the FULL resolution bitmap (no downsampling) + val fullBitmap = loadFullResolutionBitmap(context, result.uri) + + if (fullBitmap == null) { + errorMessage = "Failed to load image" + return@withContext emptyList() } - } ?: emptyList() - } - isLoading = false - // Auto-select the first (largest) face - if (croppedFaces.isNotEmpty()) { - selectedFaceIndex = 0 + + // Re-detect faces on the full resolution bitmap to get accurate bounds + val accurateFaceBounds = detectFacesOnBitmap(fullBitmap) + + if (accurateFaceBounds.isEmpty()) { + // Fallback: try to use the original bounds with scaling + val scaledBounds = result.faceBounds.map { originalBounds -> + cropFaceFromBitmap(fullBitmap, originalBounds) + } + fullBitmap.recycle() + return@withContext scaledBounds + } + + // Crop faces using accurate bounds + val croppedList = accurateFaceBounds.map { bounds -> + cropFaceFromBitmap(fullBitmap, bounds) + } + + // CRITICAL: Recycle AFTER all cropping is done + fullBitmap.recycle() + + croppedList + } + + if (croppedFaces.isEmpty() && errorMessage == null) { + errorMessage = "No faces found in full resolution image" + } + + } catch (e: Exception) { + errorMessage = "Error processing faces: ${e.message}" + } finally { + isLoading = false } } @@ -73,15 +107,18 @@ fun FacePickerDialog( ) { Card( modifier = Modifier - .fillMaxWidth(0.95f) - .fillMaxHeight(0.9f), - shape = RoundedCornerShape(16.dp) + .fillMaxWidth(0.94f) + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) ) { Column( modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { // Header Row( @@ -92,7 +129,7 @@ fun FacePickerDialog( Column { Text( text = "Pick a Face", - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold ) Text( @@ -103,14 +140,19 @@ fun FacePickerDialog( } IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, "Close") + Icon( + Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.size(24.dp) + ) } } // Instruction Text( text = "Tap a face below to select it for training:", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) if (isLoading) { @@ -118,43 +160,75 @@ fun FacePickerDialog( Box( modifier = Modifier .fillMaxWidth() - .weight(1f), + .height(200.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() - } - } else { - // Original image with face boxes overlay - Card( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - FaceOverlayImage( - imageUri = result.uri, - faceBounds = result.faceBounds, - selectedFaceIndex = selectedFaceIndex, - onFaceClick = { index -> - selectedFaceIndex = index - } + CircularProgressIndicator() + Text( + "Processing faces...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } + } else if (errorMessage != null) { + // Error state + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + text = errorMessage ?: "Unknown error", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } else { + // Original image preview + Card( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(16.dp) + ) { + AsyncImage( + model = result.uri, + contentDescription = "Original image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } - // Face previews grid + // Face previews section Text( text = "Preview (tap to select):", - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) + // Face preview cards Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) @@ -162,13 +236,41 @@ fun FacePickerDialog( croppedFaces.forEachIndexed { index, faceBitmap -> FacePreviewCard( faceBitmap = faceBitmap, - index = index, + index = index + 1, isSelected = selectedFaceIndex == index, onClick = { selectedFaceIndex = index }, modifier = Modifier.weight(1f) ) } } + + // Helper text + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + "The selected face will be used for training", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } // Action buttons @@ -178,142 +280,37 @@ fun FacePickerDialog( ) { OutlinedButton( onClick = onDismiss, - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(14.dp) ) { - Text("Cancel") + Text("Cancel", style = MaterialTheme.typography.titleMedium) } Button( onClick = { - selectedFaceIndex?.let { index -> - if (index < croppedFaces.size) { - onFaceSelected(index, croppedFaces[index]) - } + if (selectedFaceIndex < croppedFaces.size) { + onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex]) } }, - modifier = Modifier.weight(1f), - enabled = selectedFaceIndex != null && !isLoading + modifier = Modifier + .weight(1f) + .height(52.dp), + enabled = !isLoading && croppedFaces.isNotEmpty() && errorMessage == null, + shape = RoundedCornerShape(14.dp) ) { - Icon(Icons.Default.CheckCircle, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Select") - } - } - } - } - } -} - -/** - * Image with interactive face boxes overlay - */ -@Composable -private fun FaceOverlayImage( - imageUri: Uri, - faceBounds: List, - selectedFaceIndex: Int?, - onFaceClick: (Int) -> Unit -) { - var imageSize by remember { mutableStateOf(Size.Zero) } - var imageBounds by remember { mutableStateOf(Rect()) } - - Box( - modifier = Modifier.fillMaxSize() - ) { - // Original image - AsyncImage( - model = imageUri, - contentDescription = "Original image", - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentScale = ContentScale.Fit, - onSuccess = { state -> - val drawable = state.result.drawable - imageBounds = Rect(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) - } - ) - - // Face boxes overlay - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(8.dp) - ) { - if (imageBounds.width() > 0 && imageBounds.height() > 0) { - // Calculate scale to fit image in canvas - val scaleX = size.width / imageBounds.width() - val scaleY = size.height / imageBounds.height() - val scale = minOf(scaleX, scaleY) - - // Calculate offset to center image - val scaledWidth = imageBounds.width() * scale - val scaledHeight = imageBounds.height() * scale - val offsetX = (size.width - scaledWidth) / 2 - val offsetY = (size.height - scaledHeight) / 2 - - faceBounds.forEachIndexed { index, bounds -> - val isSelected = selectedFaceIndex == index - - // Scale and position the face box - val left = bounds.left * scale + offsetX - val top = bounds.top * scale + offsetY - val width = bounds.width() * scale - val height = bounds.height() * scale - - // Draw box - drawRect( - color = if (isSelected) Color(0xFF4CAF50) else Color(0xFF2196F3), - topLeft = Offset(left, top), - size = Size(width, height), - style = Stroke(width = if (isSelected) 6f else 4f) - ) - - // Draw semi-transparent fill for selected - if (isSelected) { - drawRect( - color = Color(0xFF4CAF50).copy(alpha = 0.2f), - topLeft = Offset(left, top), - size = Size(width, height) + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Use This Face", style = MaterialTheme.typography.titleMedium) } - - // Draw face number label - drawCircle( - color = if (isSelected) Color(0xFF4CAF50) else Color(0xFF2196F3), - radius = 20f * scale, - center = Offset(left + 20f * scale, top + 20f * scale) - ) } } } - - // Clickable areas for each face - faceBounds.forEachIndexed { index, bounds -> - if (imageBounds.width() > 0 && imageBounds.height() > 0) { - val scaleX = imageSize.width / imageBounds.width() - val scaleY = imageSize.height / imageBounds.height() - val scale = minOf(scaleX, scaleY) - - val scaledWidth = imageBounds.width() * scale - val scaledHeight = imageBounds.height() * scale - val offsetX = (imageSize.width - scaledWidth) / 2 - val offsetY = (imageSize.height - scaledHeight) / 2 - - Box( - modifier = Modifier - .fillMaxSize() - .clickable { onFaceClick(index) } - ) - } - } - } - - // Update image size - BoxWithConstraints { - LaunchedEffect(constraints) { - imageSize = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat()) - } } } @@ -330,69 +327,88 @@ private fun FacePreviewCard( ) { Card( modifier = modifier - .aspectRatio(1f) + .aspectRatio(0.75f) .clickable(onClick = onClick), colors = CardDefaults.cardColors( containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else - MaterialTheme.colorScheme.surface + MaterialTheme.colorScheme.surfaceVariant ), border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else - BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 8.dp else 2.dp + ) ) { - Box( - modifier = Modifier.fillMaxSize() + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { - androidx.compose.foundation.Image( - bitmap = faceBitmap.asImageBitmap(), - contentDescription = "Face ${index + 1}", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) + // Face image + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + bitmap = faceBitmap.asImageBitmap(), + contentDescription = "Face $index", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) - // Selected checkmark (only show when selected) - if (isSelected) { - Surface( - modifier = Modifier - .align(Alignment.Center), - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Selected", - modifier = Modifier - .padding(12.dp) - .size(32.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) + // Selected overlay with checkmark + if (isSelected) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + shadowElevation = 4.dp + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + modifier = Modifier + .padding(12.dp) + .size(40.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } } } - // Face number badge (always in top-right, small) + // Face number label Surface( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(4.dp), - shape = CircleShape, + modifier = Modifier.fillMaxWidth(), color = if (isSelected) MaterialTheme.colorScheme.primary else - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), - shadowElevation = 2.dp + MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) ) { Text( - text = "${index + 1}", - modifier = Modifier.padding(6.dp), - style = MaterialTheme.typography.labelSmall, + text = "Face $index", + modifier = Modifier.padding(vertical = 12.dp), + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else - MaterialTheme.colorScheme.onSurfaceVariant + MaterialTheme.colorScheme.onSurface ) } } @@ -400,14 +416,14 @@ private fun FacePreviewCard( } /** - * Helper function to load bitmap from URI + * Load full resolution bitmap WITHOUT downsampling */ -private suspend fun loadBitmapFromUri( +private suspend fun loadFullResolutionBitmap( context: android.content.Context, uri: Uri ): Bitmap? = withContext(Dispatchers.IO) { try { - val inputStream = context.contentResolver.openInputStream(uri) + val inputStream: InputStream? = context.contentResolver.openInputStream(uri) BitmapFactory.decodeStream(inputStream)?.also { inputStream?.close() } @@ -417,7 +433,34 @@ private suspend fun loadBitmapFromUri( } /** - * Helper function to crop face from bitmap + * Re-detect faces on full resolution bitmap to get accurate bounds + */ +private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List = withContext(Dispatchers.Default) { + try { + val faceDetectorOptions = FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) + .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) + .setMinFaceSize(0.15f) + .build() + + val detector = FaceDetection.getClient(faceDetectorOptions) + val inputImage = InputImage.fromBitmap(bitmap, 0) + + val faces = detector.process(inputImage).await() + + // Sort by face size (largest first) + faces.sortedByDescending { face -> + face.boundingBox.width() * face.boundingBox.height() + }.map { it.boundingBox } + + } catch (e: Exception) { + emptyList() + } +} + +/** + * Helper function to crop face from bitmap with padding */ private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap { // Add 20% padding around the face