Pre UI Sweep
This commit is contained in:
7
.idea/deviceManager.xml
generated
7
.idea/deviceManager.xml
generated
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user