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>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
<option name="order" value="DESCENDING" />
</ColumnSorterState>
</list>
</option>
<option name="groupByAttributes">
<list>
<option value="Type" />
</list>
</option>
</component>
</project>

View File

@@ -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
)

View File

@@ -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<Int?>(null) }
var selectedFaceIndex by remember { mutableStateOf(0) }
var croppedFaces by remember { mutableStateOf<List<Bitmap>>(emptyList()) }
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) {
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<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)
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<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 {
// Add 20% padding around the face