Pre UI Sweep

This commit is contained in:
genki
2026-01-11 21:06:38 -05:00
parent ae1b78e170
commit 0f6c9060bf
3 changed files with 326 additions and 286 deletions

View File

@@ -5,9 +5,14 @@
<list> <list>
<ColumnSorterState> <ColumnSorterState>
<option name="column" value="Name" /> <option name="column" value="Name" />
<option name="order" value="ASCENDING" /> <option name="order" value="DESCENDING" />
</ColumnSorterState> </ColumnSorterState>
</list> </list>
</option> </option>
<option name="groupByAttributes">
<list>
<option value="Type" />
</list>
</option>
</component> </component>
</project> </project>

View File

@@ -18,8 +18,7 @@ import androidx.compose.material.icons.filled.*
import com.placeholder.sherpai2.ui.navigation.AppRoutes import com.placeholder.sherpai2.ui.navigation.AppRoutes
/** /**
* Beautiful app drawer with sections, gradient header, and polish * SLIMMED DOWN AppDrawer - 280dp width, inline logo, cleaner sections
* UPDATED: Tour → Explore
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -28,12 +27,12 @@ fun AppDrawerContent(
onDestinationClicked: (String) -> Unit onDestinationClicked: (String) -> Unit
) { ) {
ModalDrawerSheet( ModalDrawerSheet(
modifier = Modifier.width(300.dp), modifier = Modifier.width(280.dp), // SLIMMER (was 300dp)
drawerContainerColor = MaterialTheme.colorScheme.surface drawerContainerColor = MaterialTheme.colorScheme.surface
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// ===== BEAUTIFUL GRADIENT HEADER ===== // ===== COMPACT HEADER - Icon + Text Inline =====
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -45,15 +44,16 @@ fun AppDrawerContent(
) )
) )
) )
.padding(24.dp) .padding(20.dp) // Reduced padding
) { ) {
Column( Row(
verticalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
// App icon/logo area // App icon - smaller
Surface( Surface(
modifier = Modifier.size(56.dp), modifier = Modifier.size(48.dp), // Smaller (was 56dp)
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp shadowElevation = 4.dp
) { ) {
@@ -61,44 +61,47 @@ fun AppDrawerContent(
Icon( Icon(
Icons.Default.Face, Icons.Default.Face,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onPrimary tint = MaterialTheme.colorScheme.onPrimary
) )
} }
} }
// Text next to icon
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
"SherpAI", "SherpAI",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleLarge, // Smaller (was headlineMedium)
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
"Face Recognition System", "Face Recognition System",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall, // Smaller
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp)) // Reduced spacing
// ===== NAVIGATION SECTIONS ===== // ===== NAVIGATION SECTIONS =====
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.padding(horizontal = 12.dp), .padding(horizontal = 8.dp), // Reduced padding
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(2.dp) // Tighter spacing
) { ) {
// Photos Section // Photos Section
DrawerSection(title = "Photos") DrawerSection(title = "Photos")
val photoItems = listOf( val photoItems = listOf(
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search, "Find photos by tag or person"), DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search),
DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore, "Browse smart albums") DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore)
) )
photoItems.forEach { item -> photoItems.forEach { item ->
@@ -109,15 +112,15 @@ fun AppDrawerContent(
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
// Face Recognition Section // Face Recognition Section
DrawerSection(title = "Face Recognition") DrawerSection(title = "Face Recognition")
val faceItems = listOf( val faceItems = listOf(
DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face, "Existing models"), DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face),
DrawerItem(AppRoutes.TRAIN, "Create Person", Icons.Default.ModelTraining, "New person model"), DrawerItem(AppRoutes.TRAIN, "Train New", Icons.Default.ModelTraining),
DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy, "AI model management") DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy)
) )
faceItems.forEach { item -> faceItems.forEach { item ->
@@ -128,14 +131,14 @@ fun AppDrawerContent(
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
// Organization Section // Organization Section
DrawerSection(title = "Organization") DrawerSection(title = "Organization")
val orgItems = listOf( val orgItems = listOf(
DrawerItem(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label, "Manage Tags"), DrawerItem(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label),
DrawerItem(AppRoutes.UTILITIES, "Util.", Icons.Default.NewReleases, "Manage Collection") DrawerItem(AppRoutes.UTILITIES, "Utilities", Icons.Default.Build)
) )
orgItems.forEach { item -> orgItems.forEach { item ->
@@ -150,7 +153,7 @@ fun AppDrawerContent(
// Settings at bottom // Settings at bottom
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 6.dp),
color = MaterialTheme.colorScheme.outlineVariant color = MaterialTheme.colorScheme.outlineVariant
) )
@@ -158,35 +161,34 @@ fun AppDrawerContent(
item = DrawerItem( item = DrawerItem(
AppRoutes.SETTINGS, AppRoutes.SETTINGS,
"Settings", "Settings",
Icons.Default.Settings, Icons.Default.Settings
"App preferences"
), ),
selected = AppRoutes.SETTINGS == currentRoute, selected = AppRoutes.SETTINGS == currentRoute,
onClick = { onDestinationClicked(AppRoutes.SETTINGS) } 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 @Composable
private fun DrawerSection(title: String) { private fun DrawerSection(title: String) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall, // Smaller
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, 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 @Composable
private fun DrawerNavigationItem( private fun DrawerNavigationItem(
@@ -196,33 +198,24 @@ private fun DrawerNavigationItem(
) { ) {
NavigationDrawerItem( NavigationDrawerItem(
label = { label = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyMedium, // Slightly smaller
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal 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)
)
}
}
}, },
icon = { icon = {
Icon( Icon(
item.icon, item.icon,
contentDescription = item.label, contentDescription = item.label,
modifier = Modifier.size(24.dp) modifier = Modifier.size(22.dp) // Slightly smaller
) )
}, },
selected = selected, selected = selected,
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding) .padding(NavigationDrawerItemDefaults.ItemPadding)
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(10.dp)), // Slightly smaller radius
colors = NavigationDrawerItemDefaults.colors( colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedIconColor = MaterialTheme.colorScheme.primary, 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( private data class DrawerItem(
val route: String, val route: String,
val label: String, val label: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector, val icon: androidx.compose.ui.graphics.vector.ImageVector
val subtitle: String? = null
) )

View File

@@ -5,65 +5,99 @@ import android.graphics.BitmapFactory
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage 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.Dispatchers
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream
/** /**
* Dialog for selecting a face from multiple detected faces * 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 @Composable
fun FacePickerDialog( fun FacePickerDialog(
result: FaceDetectionHelper.FaceDetectionResult, result: FaceDetectionHelper.FaceDetectionResult,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onFaceSelected: (Int, Bitmap) -> Unit // faceIndex, croppedFaceBitmap onFaceSelected: (Int, Bitmap) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var selectedFaceIndex by remember { mutableStateOf<Int?>(null) } var selectedFaceIndex by remember { mutableStateOf(0) }
var croppedFaces by remember { mutableStateOf<List<Bitmap>>(emptyList()) } var croppedFaces by remember { mutableStateOf<List<Bitmap>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Load and crop all faces // Load and crop all faces - RE-DETECT to get accurate bounds
LaunchedEffect(result) { LaunchedEffect(result) {
isLoading = true isLoading = true
errorMessage = null
try {
croppedFaces = withContext(Dispatchers.IO) { croppedFaces = withContext(Dispatchers.IO) {
val bitmap = loadBitmapFromUri(context, result.uri) // Load the FULL resolution bitmap (no downsampling)
bitmap?.let { bmp -> val fullBitmap = loadFullResolutionBitmap(context, result.uri)
result.faceBounds.map { bounds ->
cropFaceFromBitmap(bmp, bounds) if (fullBitmap == null) {
errorMessage = "Failed to load image"
return@withContext emptyList()
} }
} ?: emptyList()
// 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 isLoading = false
// Auto-select the first (largest) face
if (croppedFaces.isNotEmpty()) {
selectedFaceIndex = 0
} }
} }
@@ -73,15 +107,18 @@ fun FacePickerDialog(
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.95f) .fillMaxWidth(0.94f)
.fillMaxHeight(0.9f), .wrapContentHeight(),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(20.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
// Header // Header
Row( Row(
@@ -92,7 +129,7 @@ fun FacePickerDialog(
Column { Column {
Text( Text(
text = "Pick a Face", text = "Pick a Face",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
@@ -103,14 +140,19 @@ fun FacePickerDialog(
} }
IconButton(onClick = onDismiss) { IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close") Icon(
Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.size(24.dp)
)
} }
} }
// Instruction // Instruction
Text( Text(
text = "Tap a face below to select it for training:", text = "Tap a face below to select it for training:",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
if (isLoading) { if (isLoading) {
@@ -118,43 +160,75 @@ fun FacePickerDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .height(200.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
CircularProgressIndicator() 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 { } else {
// Original image with face boxes overlay // Original image preview
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .height(200.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceVariant
) ),
shape = RoundedCornerShape(16.dp)
) { ) {
Box( AsyncImage(
model = result.uri,
contentDescription = "Original image",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentScale = ContentScale.Fit
) {
FaceOverlayImage(
imageUri = result.uri,
faceBounds = result.faceBounds,
selectedFaceIndex = selectedFaceIndex,
onFaceClick = { index ->
selectedFaceIndex = index
}
) )
} }
}
// Face previews grid // Face previews section
Text( Text(
text = "Preview (tap to select):", text = "Preview (tap to select):",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
// Face preview cards
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
@@ -162,13 +236,41 @@ fun FacePickerDialog(
croppedFaces.forEachIndexed { index, faceBitmap -> croppedFaces.forEachIndexed { index, faceBitmap ->
FacePreviewCard( FacePreviewCard(
faceBitmap = faceBitmap, faceBitmap = faceBitmap,
index = index, index = index + 1,
isSelected = selectedFaceIndex == index, isSelected = selectedFaceIndex == index,
onClick = { selectedFaceIndex = index }, onClick = { selectedFaceIndex = index },
modifier = Modifier.weight(1f) 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 // Action buttons
@@ -178,25 +280,33 @@ fun FacePickerDialog(
) { ) {
OutlinedButton( OutlinedButton(
onClick = onDismiss, 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( Button(
onClick = { onClick = {
selectedFaceIndex?.let { index -> if (selectedFaceIndex < croppedFaces.size) {
if (index < croppedFaces.size) { onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex])
onFaceSelected(index, croppedFaces[index])
}
} }
}, },
modifier = Modifier.weight(1f), modifier = Modifier
enabled = selectedFaceIndex != null && !isLoading .weight(1f)
.height(52.dp),
enabled = !isLoading && croppedFaces.isNotEmpty() && errorMessage == null,
shape = RoundedCornerShape(14.dp)
) { ) {
Icon(Icons.Default.CheckCircle, contentDescription = null) Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Select") Text("Use This Face", style = MaterialTheme.typography.titleMedium)
} }
} }
} }
@@ -204,119 +314,6 @@ fun FacePickerDialog(
} }
} }
/**
* Image with interactive face boxes overlay
*/
@Composable
private fun FaceOverlayImage(
imageUri: Uri,
faceBounds: List<Rect>,
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)
)
}
// 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())
}
}
}
/** /**
* Individual face preview card * Individual face preview card
*/ */
@@ -330,69 +327,88 @@ private fun FacePreviewCard(
) { ) {
Card( Card(
modifier = modifier modifier = modifier
.aspectRatio(1f) .aspectRatio(0.75f)
.clickable(onClick = onClick), .clickable(onClick = onClick),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (isSelected) containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
else else
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surfaceVariant
), ),
border = if (isSelected) border = if (isSelected)
BorderStroke(3.dp, MaterialTheme.colorScheme.primary) BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
else 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
)
) { ) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Face image
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { ) {
androidx.compose.foundation.Image( Image(
bitmap = faceBitmap.asImageBitmap(), bitmap = faceBitmap.asImageBitmap(),
contentDescription = "Face ${index + 1}", contentDescription = "Face $index",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
// Selected checkmark (only show when selected) // Selected overlay with checkmark
if (isSelected) { if (isSelected) {
Surface( Surface(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.align(Alignment.Center), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp
) { ) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.CheckCircle,
contentDescription = "Selected", contentDescription = "Selected",
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(12.dp)
.size(32.dp), .size(40.dp),
tint = MaterialTheme.colorScheme.onPrimary tint = MaterialTheme.colorScheme.onPrimary
) )
} }
} }
}
}
}
// Face number badge (always in top-right, small) // Face number label
Surface( Surface(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.align(Alignment.TopEnd)
.padding(4.dp),
shape = CircleShape,
color = if (isSelected) color = if (isSelected)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else else
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)
) { ) {
Text( Text(
text = "${index + 1}", text = "Face $index",
modifier = Modifier.padding(6.dp), modifier = Modifier.padding(vertical = 12.dp),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSelected) color = if (isSelected)
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
else 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, context: android.content.Context,
uri: Uri uri: Uri
): Bitmap? = withContext(Dispatchers.IO) { ): Bitmap? = withContext(Dispatchers.IO) {
try { try {
val inputStream = context.contentResolver.openInputStream(uri) val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also { BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close() 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<Rect> = 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 { private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
// Add 20% padding around the face // Add 20% padding around the face