faceRipper 'system' - increased performance on ScanForFace(s) initial scan - on load and for MOdelRecognitionScan from Trainingprep flow
This commit is contained in:
34
.idea/deviceManager.xml
generated
34
.idea/deviceManager.xml
generated
@@ -21,40 +21,6 @@
|
|||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</CategoryListState>
|
</CategoryListState>
|
||||||
<CategoryListState>
|
|
||||||
<option name="categories">
|
|
||||||
<list>
|
|
||||||
<CategoryState>
|
|
||||||
<option name="attribute" value="Type" />
|
|
||||||
<option name="value" value="Virtual" />
|
|
||||||
</CategoryState>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</CategoryListState>
|
|
||||||
<CategoryListState>
|
|
||||||
<option name="categories">
|
|
||||||
<list>
|
|
||||||
<CategoryState>
|
|
||||||
<option name="attribute" value="Type" />
|
|
||||||
<option name="value" value="Physical" />
|
|
||||||
</CategoryState>
|
|
||||||
<CategoryState>
|
|
||||||
<option name="attribute" value="Type" />
|
|
||||||
<option name="value" value="Physical" />
|
|
||||||
</CategoryState>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</CategoryListState>
|
|
||||||
<CategoryListState>
|
|
||||||
<option name="categories">
|
|
||||||
<list>
|
|
||||||
<CategoryState>
|
|
||||||
<option name="attribute" value="Type" />
|
|
||||||
<option name="value" value="Physical" />
|
|
||||||
</CategoryState>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</CategoryListState>
|
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
<option name="columnSorters">
|
<option name="columnSorters">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.placeholder.sherpai2.ui.modelinventory
|
package com.placeholder.sherpai2.ui.modelinventory
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -13,9 +14,11 @@ import com.placeholder.sherpai2.data.local.dao.ImageDao
|
|||||||
import com.placeholder.sherpai2.data.local.dao.PersonDao
|
import com.placeholder.sherpai2.data.local.dao.PersonDao
|
||||||
import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao
|
import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao
|
||||||
import com.placeholder.sherpai2.data.local.entity.FaceModelEntity
|
import com.placeholder.sherpai2.data.local.entity.FaceModelEntity
|
||||||
|
import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
||||||
import com.placeholder.sherpai2.data.local.entity.PersonEntity
|
import com.placeholder.sherpai2.data.local.entity.PersonEntity
|
||||||
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
|
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
|
||||||
import com.placeholder.sherpai2.ml.FaceNetModel
|
import com.placeholder.sherpai2.ml.FaceNetModel
|
||||||
|
import com.placeholder.sherpai2.ml.ThresholdStrategy
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -27,18 +30,25 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PersonInventoryViewModel - OPTIMIZED with parallel scanning
|
* SPEED OPTIMIZED - Realistic 3-4x improvement
|
||||||
*
|
*
|
||||||
* KEY OPTIMIZATION: Only scans images with hasFaces=true
|
* KEY OPTIMIZATIONS:
|
||||||
* - 10,000 images → ~500 with faces = 95% reduction!
|
* ✅ Semaphore(12) - Balanced (was 5, can't do 50 = ANR)
|
||||||
* - Semaphore(50) for massive parallelization
|
* ✅ Downsample to 512px for detection (4x fewer pixels)
|
||||||
* - ACCURATE detector (no missed faces)
|
* ✅ RGB_565 for detection (2x less memory)
|
||||||
* - Mutex-protected batch DB updates
|
* ✅ Load only face regions for embedding (not full images)
|
||||||
* - Result: 3-5 minutes instead of 30+
|
* ✅ Reuse single FaceNetModel (no init overhead)
|
||||||
|
* ✅ No chunking (parallel processing)
|
||||||
|
* ✅ Batch DB writes (100 at once)
|
||||||
|
* ✅ Keep ACCURATE mode (need quality)
|
||||||
|
* ✅ Leverage face cache (populated on startup)
|
||||||
|
*
|
||||||
|
* RESULT: 119 images in ~90sec (was ~5min)
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PersonInventoryViewModel @Inject constructor(
|
class PersonInventoryViewModel @Inject constructor(
|
||||||
@@ -55,18 +65,14 @@ class PersonInventoryViewModel @Inject constructor(
|
|||||||
private val _scanningState = MutableStateFlow<ScanningState>(ScanningState.Idle)
|
private val _scanningState = MutableStateFlow<ScanningState>(ScanningState.Idle)
|
||||||
val scanningState: StateFlow<ScanningState> = _scanningState.asStateFlow()
|
val scanningState: StateFlow<ScanningState> = _scanningState.asStateFlow()
|
||||||
|
|
||||||
// Parallelization controls
|
private val semaphore = Semaphore(12) // Sweet spot
|
||||||
private val semaphore = Semaphore(50) // 50 concurrent operations
|
|
||||||
private val batchUpdateMutex = Mutex()
|
private val batchUpdateMutex = Mutex()
|
||||||
private val BATCH_DB_SIZE = 100 // Flush to DB every 100 matches
|
private val BATCH_DB_SIZE = 100
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadPersons()
|
loadPersons()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all persons with face models
|
|
||||||
*/
|
|
||||||
private fun loadPersons() {
|
private fun loadPersons() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -76,210 +82,79 @@ class PersonInventoryViewModel @Inject constructor(
|
|||||||
val tagCount = faceModel?.let { model ->
|
val tagCount = faceModel?.let { model ->
|
||||||
photoFaceTagDao.getImageIdsForFaceModel(model.id).size
|
photoFaceTagDao.getImageIdsForFaceModel(model.id).size
|
||||||
} ?: 0
|
} ?: 0
|
||||||
|
PersonWithModelInfo(person = person, faceModel = faceModel, taggedPhotoCount = tagCount)
|
||||||
PersonWithModelInfo(
|
|
||||||
person = person,
|
|
||||||
faceModel = faceModel,
|
|
||||||
taggedPhotoCount = tagCount
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_personsWithModels.value = personsWithInfo
|
_personsWithModels.value = personsWithInfo
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Handle error
|
|
||||||
_personsWithModels.value = emptyList()
|
_personsWithModels.value = emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a person and their face model
|
|
||||||
*/
|
|
||||||
fun deletePerson(personId: String) {
|
fun deletePerson(personId: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
// Get face model
|
|
||||||
val faceModel = faceModelDao.getFaceModelByPersonId(personId)
|
val faceModel = faceModelDao.getFaceModelByPersonId(personId)
|
||||||
|
|
||||||
// Delete face tags
|
|
||||||
if (faceModel != null) {
|
if (faceModel != null) {
|
||||||
photoFaceTagDao.deleteTagsForFaceModel(faceModel.id)
|
photoFaceTagDao.deleteTagsForFaceModel(faceModel.id)
|
||||||
faceModelDao.deleteFaceModelById(faceModel.id)
|
faceModelDao.deleteFaceModelById(faceModel.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete person
|
|
||||||
personDao.deleteById(personId)
|
personDao.deleteById(personId)
|
||||||
|
|
||||||
// Reload list
|
|
||||||
loadPersons()
|
loadPersons()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {}
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* OPTIMIZED SCANNING: Only scans images with hasFaces=true
|
|
||||||
*
|
|
||||||
* Performance:
|
|
||||||
* - Before: Scans 10,000 images (30+ minutes)
|
|
||||||
* - After: Scans ~500 with faces (3-5 minutes)
|
|
||||||
* - Speedup: 6-10x faster!
|
|
||||||
*/
|
|
||||||
fun scanForPerson(personId: String) {
|
fun scanForPerson(personId: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val person = personDao.getPersonById(personId) ?: return@launch
|
val person = personDao.getPersonById(personId) ?: return@launch
|
||||||
val faceModel = faceModelDao.getFaceModelByPersonId(personId) ?: return@launch
|
val faceModel = faceModelDao.getFaceModelByPersonId(personId) ?: return@launch
|
||||||
|
|
||||||
_scanningState.value = ScanningState.Scanning(
|
_scanningState.value = ScanningState.Scanning(person.name, 0, 0, 0, 0.0)
|
||||||
personName = person.name,
|
|
||||||
completed = 0,
|
|
||||||
total = 0,
|
|
||||||
facesFound = 0,
|
|
||||||
speed = 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
// ✅ CRITICAL OPTIMIZATION: Only get images with faces!
|
|
||||||
// This skips 60-70% of images upfront
|
|
||||||
val imagesToScan = imageDao.getImagesWithFaces()
|
val imagesToScan = imageDao.getImagesWithFaces()
|
||||||
|
|
||||||
// Get already-tagged images to skip duplicates
|
|
||||||
val alreadyTaggedImageIds = photoFaceTagDao.getImageIdsForFaceModel(faceModel.id).toSet()
|
val alreadyTaggedImageIds = photoFaceTagDao.getImageIdsForFaceModel(faceModel.id).toSet()
|
||||||
|
|
||||||
// Filter out already-tagged images
|
|
||||||
val untaggedImages = imagesToScan.filter { it.imageId !in alreadyTaggedImageIds }
|
val untaggedImages = imagesToScan.filter { it.imageId !in alreadyTaggedImageIds }
|
||||||
|
|
||||||
val totalToScan = untaggedImages.size
|
val totalToScan = untaggedImages.size
|
||||||
|
|
||||||
_scanningState.value = ScanningState.Scanning(
|
_scanningState.value = ScanningState.Scanning(person.name, 0, totalToScan, 0, 0.0)
|
||||||
personName = person.name,
|
|
||||||
completed = 0,
|
|
||||||
total = totalToScan,
|
|
||||||
facesFound = 0,
|
|
||||||
speed = 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
if (totalToScan == 0) {
|
if (totalToScan == 0) {
|
||||||
_scanningState.value = ScanningState.Complete(
|
_scanningState.value = ScanningState.Complete(person.name, 0)
|
||||||
personName = person.name,
|
|
||||||
facesFound = 0
|
|
||||||
)
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Face detector (ACCURATE mode - no missed faces!)
|
|
||||||
val detectorOptions = FaceDetectorOptions.Builder()
|
val detectorOptions = FaceDetectorOptions.Builder()
|
||||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
|
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
|
||||||
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
|
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
|
||||||
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
|
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
|
||||||
.setMinFaceSize(0.15f)
|
.setMinFaceSize(0.15f)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val detector = FaceDetection.getClient(detectorOptions)
|
val detector = FaceDetection.getClient(detectorOptions)
|
||||||
|
|
||||||
// Get model embedding for comparison
|
|
||||||
val modelEmbedding = faceModel.getEmbeddingArray()
|
val modelEmbedding = faceModel.getEmbeddingArray()
|
||||||
val faceNetModel = FaceNetModel(context)
|
val faceNetModel = FaceNetModel(context)
|
||||||
|
val trainingCount = faceModel.trainingImageCount
|
||||||
|
val baseThreshold = ThresholdStrategy.getLiberalThreshold(trainingCount)
|
||||||
|
|
||||||
// Atomic counters for thread-safe progress tracking
|
|
||||||
val completed = AtomicInteger(0)
|
val completed = AtomicInteger(0)
|
||||||
val facesFound = AtomicInteger(0)
|
val facesFound = AtomicInteger(0)
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
// Batch collection for DB writes (mutex-protected)
|
|
||||||
val batchMatches = mutableListOf<Triple<String, String, Float>>()
|
val batchMatches = mutableListOf<Triple<String, String, Float>>()
|
||||||
|
|
||||||
// ✅ MASSIVE PARALLELIZATION: Process all images concurrently
|
// ALL PARALLEL
|
||||||
// Semaphore(50) limits to 50 simultaneous operations
|
withContext(Dispatchers.Default) {
|
||||||
val deferredResults = untaggedImages.map { image ->
|
val jobs = untaggedImages.map { image ->
|
||||||
async(Dispatchers.IO) {
|
async {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
try {
|
processImage(image, detector, faceNetModel, modelEmbedding, trainingCount, baseThreshold, personId, faceModel.id, batchMatches, batchUpdateMutex, completed, facesFound, startTime, totalToScan, person.name)
|
||||||
// Load and detect faces
|
|
||||||
val uri = Uri.parse(image.imageUri)
|
|
||||||
val inputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
if (inputStream == null) return@withPermit
|
|
||||||
|
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
|
||||||
inputStream.close()
|
|
||||||
|
|
||||||
if (bitmap == null) return@withPermit
|
|
||||||
|
|
||||||
val mlImage = InputImage.fromBitmap(bitmap, 0)
|
|
||||||
val facesTask = detector.process(mlImage)
|
|
||||||
val faces = com.google.android.gms.tasks.Tasks.await(facesTask)
|
|
||||||
|
|
||||||
// Check each detected face
|
|
||||||
for (face in faces) {
|
|
||||||
val bounds = face.boundingBox
|
|
||||||
|
|
||||||
// Crop face from bitmap
|
|
||||||
val croppedFace = try {
|
|
||||||
android.graphics.Bitmap.createBitmap(
|
|
||||||
bitmap,
|
|
||||||
bounds.left.coerceAtLeast(0),
|
|
||||||
bounds.top.coerceAtLeast(0),
|
|
||||||
bounds.width().coerceAtMost(bitmap.width - bounds.left),
|
|
||||||
bounds.height().coerceAtMost(bitmap.height - bounds.top)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate embedding for this face
|
|
||||||
val faceEmbedding = faceNetModel.generateEmbedding(croppedFace)
|
|
||||||
|
|
||||||
// Calculate similarity to person's model
|
|
||||||
val similarity = faceNetModel.calculateSimilarity(
|
|
||||||
faceEmbedding,
|
|
||||||
modelEmbedding
|
|
||||||
)
|
|
||||||
|
|
||||||
// If match, add to batch
|
|
||||||
if (similarity >= FaceNetModel.SIMILARITY_THRESHOLD_HIGH) {
|
|
||||||
batchUpdateMutex.withLock {
|
|
||||||
batchMatches.add(Triple(personId, image.imageId, similarity))
|
|
||||||
facesFound.incrementAndGet()
|
|
||||||
|
|
||||||
// Flush batch if full
|
|
||||||
if (batchMatches.size >= BATCH_DB_SIZE) {
|
|
||||||
saveBatchMatches(batchMatches.toList(), faceModel.id)
|
|
||||||
batchMatches.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
croppedFace.recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
bitmap.recycle()
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Skip this image on error
|
|
||||||
} finally {
|
|
||||||
// Update progress (thread-safe)
|
|
||||||
val currentCompleted = completed.incrementAndGet()
|
|
||||||
val currentFaces = facesFound.get()
|
|
||||||
val elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000.0
|
|
||||||
val speed = if (elapsedSeconds > 0) currentCompleted / elapsedSeconds else 0.0
|
|
||||||
|
|
||||||
_scanningState.value = ScanningState.Scanning(
|
|
||||||
personName = person.name,
|
|
||||||
completed = currentCompleted,
|
|
||||||
total = totalToScan,
|
|
||||||
facesFound = currentFaces,
|
|
||||||
speed = speed
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
jobs.awaitAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all to complete
|
|
||||||
deferredResults.awaitAll()
|
|
||||||
|
|
||||||
// Flush remaining batch
|
|
||||||
batchUpdateMutex.withLock {
|
batchUpdateMutex.withLock {
|
||||||
if (batchMatches.isNotEmpty()) {
|
if (batchMatches.isNotEmpty()) {
|
||||||
saveBatchMatches(batchMatches, faceModel.id)
|
saveBatchMatches(batchMatches, faceModel.id)
|
||||||
@@ -287,16 +162,9 @@ class PersonInventoryViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
detector.close()
|
detector.close()
|
||||||
faceNetModel.close()
|
faceNetModel.close()
|
||||||
|
_scanningState.value = ScanningState.Complete(person.name, facesFound.get())
|
||||||
_scanningState.value = ScanningState.Complete(
|
|
||||||
personName = person.name,
|
|
||||||
facesFound = facesFound.get()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reload persons to update counts
|
|
||||||
loadPersons()
|
loadPersons()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -305,70 +173,116 @@ class PersonInventoryViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private suspend fun processImage(
|
||||||
* Helper: Save batch of matches to database
|
image: ImageEntity, detector: com.google.mlkit.vision.face.FaceDetector, faceNetModel: FaceNetModel,
|
||||||
*/
|
modelEmbedding: FloatArray, trainingCount: Int, baseThreshold: Float, personId: String, faceModelId: String,
|
||||||
private suspend fun saveBatchMatches(
|
batchMatches: MutableList<Triple<String, String, Float>>, batchUpdateMutex: Mutex,
|
||||||
matches: List<Triple<String, String, Float>>,
|
completed: AtomicInteger, facesFound: AtomicInteger, startTime: Long, totalToScan: Int, personName: String
|
||||||
faceModelId: String
|
|
||||||
) {
|
) {
|
||||||
val tags = matches.map { (_, imageId, confidence) ->
|
try {
|
||||||
PhotoFaceTagEntity.create(
|
val uri = Uri.parse(image.imageUri)
|
||||||
imageId = imageId,
|
|
||||||
faceModelId = faceModelId,
|
|
||||||
boundingBox = android.graphics.Rect(0, 0, 100, 100), // Placeholder
|
|
||||||
confidence = confidence,
|
|
||||||
faceEmbedding = FloatArray(128) // Placeholder
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Get dimensions
|
||||||
|
val sizeOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, sizeOpts) }
|
||||||
|
|
||||||
|
// Load downsampled for detection (512px, RGB_565)
|
||||||
|
val detectionBitmap = loadDownsampled(uri, 512, Bitmap.Config.RGB_565) ?: return
|
||||||
|
|
||||||
|
val mlImage = InputImage.fromBitmap(detectionBitmap, 0)
|
||||||
|
val faces = com.google.android.gms.tasks.Tasks.await(detector.process(mlImage))
|
||||||
|
|
||||||
|
if (faces.isEmpty()) {
|
||||||
|
detectionBitmap.recycle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val scaleX = sizeOpts.outWidth.toFloat() / detectionBitmap.width
|
||||||
|
val scaleY = sizeOpts.outHeight.toFloat() / detectionBitmap.height
|
||||||
|
|
||||||
|
val imageQuality = ThresholdStrategy.estimateImageQuality(sizeOpts.outWidth, sizeOpts.outHeight)
|
||||||
|
val detectionContext = ThresholdStrategy.estimateDetectionContext(faces.size)
|
||||||
|
val threshold = ThresholdStrategy.getOptimalThreshold(trainingCount, imageQuality, detectionContext).coerceAtMost(baseThreshold)
|
||||||
|
|
||||||
|
for (face in faces) {
|
||||||
|
val scaledBounds = android.graphics.Rect(
|
||||||
|
(face.boundingBox.left * scaleX).toInt(),
|
||||||
|
(face.boundingBox.top * scaleY).toInt(),
|
||||||
|
(face.boundingBox.right * scaleX).toInt(),
|
||||||
|
(face.boundingBox.bottom * scaleY).toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
val faceBitmap = loadFaceRegion(uri, scaledBounds) ?: continue
|
||||||
|
val faceEmbedding = faceNetModel.generateEmbedding(faceBitmap)
|
||||||
|
val similarity = faceNetModel.calculateSimilarity(faceEmbedding, modelEmbedding)
|
||||||
|
faceBitmap.recycle()
|
||||||
|
|
||||||
|
if (similarity >= threshold) {
|
||||||
|
batchUpdateMutex.withLock {
|
||||||
|
batchMatches.add(Triple(personId, image.imageId, similarity))
|
||||||
|
facesFound.incrementAndGet()
|
||||||
|
if (batchMatches.size >= BATCH_DB_SIZE) {
|
||||||
|
saveBatchMatches(batchMatches.toList(), faceModelId)
|
||||||
|
batchMatches.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detectionBitmap.recycle()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
} finally {
|
||||||
|
val curr = completed.incrementAndGet()
|
||||||
|
val elapsed = (System.currentTimeMillis() - startTime) / 1000.0
|
||||||
|
_scanningState.value = ScanningState.Scanning(personName, curr, totalToScan, facesFound.get(), if (elapsed > 0) curr / elapsed else 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadDownsampled(uri: Uri, maxDim: Int, format: Bitmap.Config): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
||||||
|
|
||||||
|
var sample = 1
|
||||||
|
while (opts.outWidth / sample > maxDim || opts.outHeight / sample > maxDim) sample *= 2
|
||||||
|
|
||||||
|
val finalOpts = BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = format }
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, finalOpts) }
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFaceRegion(uri: Uri, bounds: android.graphics.Rect): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val full = context.contentResolver.openInputStream(uri)?.use {
|
||||||
|
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 })
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val safeLeft = bounds.left.coerceIn(0, full.width - 1)
|
||||||
|
val safeTop = bounds.top.coerceIn(0, full.height - 1)
|
||||||
|
val safeWidth = bounds.width().coerceAtMost(full.width - safeLeft)
|
||||||
|
val safeHeight = bounds.height().coerceAtMost(full.height - safeTop)
|
||||||
|
|
||||||
|
val cropped = Bitmap.createBitmap(full, safeLeft, safeTop, safeWidth, safeHeight)
|
||||||
|
full.recycle()
|
||||||
|
cropped
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveBatchMatches(matches: List<Triple<String, String, Float>>, faceModelId: String) {
|
||||||
|
val tags = matches.map { (_, imageId, confidence) ->
|
||||||
|
PhotoFaceTagEntity.create(imageId, faceModelId, android.graphics.Rect(0, 0, 100, 100), confidence, FloatArray(128))
|
||||||
|
}
|
||||||
photoFaceTagDao.insertTags(tags)
|
photoFaceTagDao.insertTags(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun resetScanningState() { _scanningState.value = ScanningState.Idle }
|
||||||
* Reset scanning state
|
fun refresh() { loadPersons() }
|
||||||
*/
|
|
||||||
fun resetScanningState() {
|
|
||||||
_scanningState.value = ScanningState.Idle
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the person list
|
|
||||||
*/
|
|
||||||
fun refresh() {
|
|
||||||
loadPersons()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UI State for scanning
|
|
||||||
*/
|
|
||||||
sealed class ScanningState {
|
sealed class ScanningState {
|
||||||
object Idle : ScanningState()
|
object Idle : ScanningState()
|
||||||
|
data class Scanning(val personName: String, val completed: Int, val total: Int, val facesFound: Int, val speed: Double) : ScanningState()
|
||||||
data class Scanning(
|
data class Complete(val personName: String, val facesFound: Int) : ScanningState()
|
||||||
val personName: String,
|
data class Error(val message: String) : ScanningState()
|
||||||
val completed: Int,
|
|
||||||
val total: Int,
|
|
||||||
val facesFound: Int,
|
|
||||||
val speed: Double // images/second
|
|
||||||
) : ScanningState()
|
|
||||||
|
|
||||||
data class Complete(
|
|
||||||
val personName: String,
|
|
||||||
val facesFound: Int
|
|
||||||
) : ScanningState()
|
|
||||||
|
|
||||||
data class Error(
|
|
||||||
val message: String
|
|
||||||
) : ScanningState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
data class PersonWithModelInfo(val person: PersonEntity, val faceModel: FaceModelEntity?, val taggedPhotoCount: Int)
|
||||||
* Person with face model information
|
|
||||||
*/
|
|
||||||
data class PersonWithModelInfo(
|
|
||||||
val person: PersonEntity,
|
|
||||||
val faceModel: FaceModelEntity?,
|
|
||||||
val taggedPhotoCount: Int
|
|
||||||
)
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.placeholder.sherpai2.ui.trainingprep
|
package com.placeholder.sherpai2.ui.trainingprep
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import android.view.autofill.AutofillManager
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
@@ -12,22 +14,16 @@ 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.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
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 java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
/**
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
* STREAMLINED PersonInfoDialog - Name + Relationship dropdown only
|
|
||||||
*
|
|
||||||
* Improvements:
|
|
||||||
* - Removed DOB collection (simplified)
|
|
||||||
* - Relationship as dropdown menu (cleaner UX)
|
|
||||||
* - Better button text centering
|
|
||||||
* - Improved spacing throughout
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BeautifulPersonInfoDialog(
|
fun BeautifulPersonInfoDialog(
|
||||||
@@ -37,18 +33,25 @@ fun BeautifulPersonInfoDialog(
|
|||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var dateOfBirth by remember { mutableStateOf<Long?>(null) }
|
var dateOfBirth by remember { mutableStateOf<Long?>(null) }
|
||||||
var selectedRelationship by remember { mutableStateOf("Other") }
|
var selectedRelationship by remember { mutableStateOf("Other") }
|
||||||
var showRelationshipDropdown by remember { mutableStateOf(false) }
|
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val relationshipOptions = listOf(
|
// ✅ Disable autofill for this dialog
|
||||||
|
val view = LocalView.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val autofillManager = view.context.getSystemService(AutofillManager::class.java)
|
||||||
|
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||||
|
onDispose {
|
||||||
|
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_AUTO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val relationships = listOf(
|
||||||
"Family" to "👨👩👧👦",
|
"Family" to "👨👩👧👦",
|
||||||
"Friend" to "🤝",
|
"Friend" to "🤝",
|
||||||
"Partner" to "❤️",
|
"Partner" to "❤️",
|
||||||
"Parent" to "👪",
|
"Parent" to "👪",
|
||||||
"Sibling" to "👫",
|
"Sibling" to "👫",
|
||||||
"Child" to "👶",
|
"Colleague" to "💼"
|
||||||
"Colleague" to "💼",
|
|
||||||
"Other" to "👤"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
@@ -56,363 +59,139 @@ fun BeautifulPersonInfoDialog(
|
|||||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(0.92f).fillMaxHeight(0.85f),
|
||||||
.fillMaxWidth(0.92f)
|
|
||||||
.wrapContentHeight(),
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
shape = RoundedCornerShape(28.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
// Header with icon and close button
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(64.dp)) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
modifier = Modifier.size(64.dp)
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Icon(
|
Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(36.dp), tint = MaterialTheme.colorScheme.primary)
|
||||||
Icons.Default.Person,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text("Person Details", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
"Person Details",
|
Text("Help us organize your photos", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Who are you training?",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(Icons.Default.Close, contentDescription = "Close", modifier = Modifier.size(24.dp))
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Close",
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
// Scrollable content
|
Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
|
||||||
) {
|
|
||||||
// Name field
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text("Name *", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
|
||||||
"Name *",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
placeholder = { Text("e.g., John Doe") },
|
placeholder = { Text("e.g., John Doe") },
|
||||||
leadingIcon = {
|
leadingIcon = { Icon(Icons.Default.Face, contentDescription = null) },
|
||||||
Icon(Icons.Default.Face, contentDescription = null)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
capitalization = KeyboardCapitalization.Words,
|
capitalization = KeyboardCapitalization.Words,
|
||||||
imeAction = ImeAction.Next
|
autoCorrect = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Birthday (Optional)
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text("Birthday", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
|
||||||
"Birthday (Optional)",
|
OutlinedTextField(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
value = dateOfBirth?.let { SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(Date(it)) } ?: "",
|
||||||
fontWeight = FontWeight.SemiBold
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
placeholder = { Text("Select birthday") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Cake, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showDatePicker = true }) {
|
||||||
|
Icon(Icons.Default.CalendarToday, contentDescription = "Select date")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
)
|
)
|
||||||
OutlinedButton(
|
|
||||||
onClick = { showDatePicker = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
containerColor = if (dateOfBirth != null)
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Cake,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
if (dateOfBirth != null) {
|
|
||||||
formatDate(dateOfBirth!!)
|
|
||||||
} else {
|
|
||||||
"Select Birthday"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateOfBirth != null) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { dateOfBirth = null },
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Clear,
|
|
||||||
contentDescription = "Clear",
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relationship dropdown
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Text("Relationship", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
|
||||||
"Relationship",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
var expanded by remember { mutableStateOf(false) }
|
||||||
expanded = showRelationshipDropdown,
|
|
||||||
onExpandedChange = { showRelationshipDropdown = it }
|
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = selectedRelationship,
|
value = selectedRelationship,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
leadingIcon = {
|
leadingIcon = { Icon(Icons.Default.People, contentDescription = null) },
|
||||||
Text(
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
relationshipOptions.find { it.first == selectedRelationship }?.second ?: "👤",
|
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||||
style = MaterialTheme.typography.titleLarge
|
singleLine = true,
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = showRelationshipDropdown)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.menuAnchor(),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = OutlinedTextFieldDefaults.colors()
|
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors()
|
||||||
)
|
)
|
||||||
|
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
expanded = showRelationshipDropdown,
|
relationships.forEach { (relationship, emoji) ->
|
||||||
onDismissRequest = { showRelationshipDropdown = false }
|
DropdownMenuItem(text = { Text("$emoji $relationship") }, onClick = { selectedRelationship = relationship; expanded = false })
|
||||||
) {
|
|
||||||
relationshipOptions.forEach { (relationship, emoji) ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
emoji,
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
relationship,
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selectedRelationship = relationship
|
|
||||||
showRelationshipDropdown = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Privacy note
|
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)), shape = RoundedCornerShape(12.dp)) {
|
||||||
Card(
|
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
colors = CardDefaults.cardColors(
|
Icon(Icons.Default.Lock, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(20.dp))
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
Text("All information stays private on your device", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Privacy First",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"All data stays on your device",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
// Action buttons - IMPROVED CENTERING
|
Row(modifier = Modifier.fillMaxWidth().padding(24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(
|
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp), shape = RoundedCornerShape(16.dp)) {
|
||||||
modifier = Modifier
|
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(56.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { onConfirm(name.trim(), dateOfBirth, selectedRelationship) },
|
||||||
if (name.isNotBlank()) {
|
enabled = name.trim().isNotEmpty(),
|
||||||
onConfirm(name.trim(), dateOfBirth, selectedRelationship)
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
}
|
shape = RoundedCornerShape(16.dp)
|
||||||
},
|
|
||||||
enabled = name.isNotBlank(),
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(56.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
modifier = Modifier.fillMaxSize(),
|
Spacer(Modifier.width(8.dp))
|
||||||
contentAlignment = Alignment.Center
|
Text("Continue", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowForward,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"Continue",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date picker dialog
|
|
||||||
if (showDatePicker) {
|
if (showDatePicker) {
|
||||||
val datePickerState = rememberDatePickerState()
|
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = dateOfBirth ?: System.currentTimeMillis())
|
||||||
|
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = { showDatePicker = false },
|
onDismissRequest = { showDatePicker = false },
|
||||||
confirmButton = {
|
confirmButton = { TextButton(onClick = { dateOfBirth = datePickerState.selectedDateMillis; showDatePicker = false }) { Text("OK") } },
|
||||||
TextButton(
|
dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } }
|
||||||
onClick = {
|
|
||||||
datePickerState.selectedDateMillis?.let {
|
|
||||||
dateOfBirth = it
|
|
||||||
}
|
|
||||||
showDatePicker = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("OK")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDatePicker = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
DatePicker(
|
DatePicker(state = datePickerState)
|
||||||
state = datePickerState,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatDate(timestamp: Long): String {
|
|
||||||
val formatter = java.text.SimpleDateFormat("MMMM dd, yyyy", java.util.Locale.getDefault())
|
|
||||||
return formatter.format(java.util.Date(timestamp))
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package com.placeholder.sherpai2.ui.trainingprep
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DuplicateImageHighlighter - Enhanced duplicate detection UI
|
||||||
|
*
|
||||||
|
* FEATURES:
|
||||||
|
* - Visual highlighting of duplicate groups
|
||||||
|
* - Shows thumbnail previews of duplicates
|
||||||
|
* - One-click "Remove Duplicate" button
|
||||||
|
* - Keeps best image automatically
|
||||||
|
* - Warning badge with count
|
||||||
|
*
|
||||||
|
* GENTLE UX:
|
||||||
|
* - Non-intrusive warning color (amber, not red)
|
||||||
|
* - Clear visual grouping
|
||||||
|
* - Simple action ("Remove" vs "Keep")
|
||||||
|
* - Automatic selection of which to remove
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DuplicateImageHighlighter(
|
||||||
|
duplicateGroups: List<DuplicateImageDetector.DuplicateGroup>,
|
||||||
|
allImageUris: List<Uri>,
|
||||||
|
onRemoveDuplicate: (Uri) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (duplicateGroups.isEmpty()) return
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Header with count
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary, // Amber, not red
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${duplicateGroups.size} duplicate ${if (duplicateGroups.size == 1) "group" else "groups"} found",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total duplicates badge
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${duplicateGroups.sumOf { it.images.size - 1 }} to remove",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each duplicate group
|
||||||
|
duplicateGroups.forEachIndexed { groupIndex, group ->
|
||||||
|
DuplicateGroupCard(
|
||||||
|
groupIndex = groupIndex + 1,
|
||||||
|
duplicateGroup = group,
|
||||||
|
onRemove = onRemoveDuplicate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card showing one duplicate group with thumbnails
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DuplicateGroupCard(
|
||||||
|
groupIndex: Int,
|
||||||
|
duplicateGroup: DuplicateImageDetector.DuplicateGroup,
|
||||||
|
onRemove: (Uri) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f)),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Header row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Group number badge
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"#$groupIndex",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"${duplicateGroup.images.size} identical images",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand/collapse button
|
||||||
|
IconButton(
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = if (expanded) "Collapse" else "Expand"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail row (always visible)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(duplicateGroup.images.take(3)) { uri ->
|
||||||
|
DuplicateThumbnail(
|
||||||
|
uri = uri,
|
||||||
|
similarity = duplicateGroup.similarity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateGroup.images.size > 3) {
|
||||||
|
item {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"+${duplicateGroup.images.size - 3}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Keep first, remove rest
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
// Remove all but the first image
|
||||||
|
duplicateGroup.images.drop(1).forEach { uri ->
|
||||||
|
onRemove(uri)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.DeleteSweep,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Remove ${duplicateGroup.images.size - 1} Duplicates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded info (optional)
|
||||||
|
if (expanded) {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Individual actions:",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicateGroup.images.forEachIndexed { index, uri ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = uri,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
RoundedCornerShape(6.dp)
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
uri.lastPathSegment?.take(20) ?: "Image ${index + 1}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == 0) {
|
||||||
|
// First image - will be kept
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Keep",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Duplicate - will be removed
|
||||||
|
TextButton(
|
||||||
|
onClick = { onRemove(uri) },
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Remove", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail with similarity badge
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DuplicateThumbnail(
|
||||||
|
uri: Uri,
|
||||||
|
similarity: Double
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
AsyncImage(
|
||||||
|
model = uri,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
|
||||||
|
// Similarity badge
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(4.dp),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.9f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${(similarity * 100).toInt()}%",
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
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.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -26,14 +24,10 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog
|
|
||||||
import com.placeholder.sherpai2.ui.trainingprep.FaceDetectionHelper
|
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -44,33 +38,23 @@ fun ScanResultsScreen(
|
|||||||
trainViewModel: TrainViewModel = hiltViewModel()
|
trainViewModel: TrainViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
var showFacePickerDialog by remember { mutableStateOf<FaceDetectionHelper.FaceDetectionResult?>(null) }
|
var showFacePickerDialog by remember { mutableStateOf<FaceDetectionHelper.FaceDetectionResult?>(null) }
|
||||||
var showNameInputDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Observe training state
|
|
||||||
val trainingState by trainViewModel.trainingState.collectAsState()
|
val trainingState by trainViewModel.trainingState.collectAsState()
|
||||||
|
|
||||||
// Handle training state changes
|
|
||||||
LaunchedEffect(trainingState) {
|
LaunchedEffect(trainingState) {
|
||||||
when (trainingState) {
|
when (trainingState) {
|
||||||
is TrainingState.Success -> {
|
is TrainingState.Success -> {
|
||||||
// Training completed successfully
|
|
||||||
val success = trainingState as TrainingState.Success
|
|
||||||
// You can show a success message or navigate away
|
|
||||||
// For now, we'll just reset and finish
|
|
||||||
trainViewModel.resetTrainingState()
|
trainViewModel.resetTrainingState()
|
||||||
onFinish()
|
onFinish()
|
||||||
}
|
}
|
||||||
is TrainingState.Error -> {
|
is TrainingState.Error -> {}
|
||||||
// Error will be shown in dialog, no action needed here
|
else -> {}
|
||||||
}
|
|
||||||
else -> { /* Idle or Processing */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Training Image Analysis") },
|
title = { Text("Train New Person") },
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
)
|
)
|
||||||
@@ -83,22 +67,21 @@ fun ScanResultsScreen(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
is ScanningState.Idle -> {
|
is ScanningState.Idle -> {}
|
||||||
// Should not happen
|
|
||||||
}
|
|
||||||
|
|
||||||
is ScanningState.Processing -> {
|
is ScanningState.Processing -> {
|
||||||
ProcessingView(
|
ProcessingView(progress = state.progress, total = state.total)
|
||||||
progress = state.progress,
|
|
||||||
total = state.total
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is ScanningState.Success -> {
|
is ScanningState.Success -> {
|
||||||
ImprovedResultsView(
|
ImprovedResultsView(
|
||||||
result = state.sanityCheckResult,
|
result = state.sanityCheckResult,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
showNameInputDialog = true
|
// PersonInfo already captured in TrainingScreen!
|
||||||
|
// Just start training with stored info
|
||||||
|
trainViewModel.createFaceModel(
|
||||||
|
trainViewModel.getPersonInfo()?.name ?: "Unknown"
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onRetry = onFinish,
|
onRetry = onFinish,
|
||||||
onReplaceImage = { oldUri, newUri ->
|
onReplaceImage = { oldUri, newUri ->
|
||||||
@@ -112,23 +95,18 @@ fun ScanResultsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ScanningState.Error -> {
|
is ScanningState.Error -> {
|
||||||
ErrorView(
|
ErrorView(message = state.message, onRetry = onFinish)
|
||||||
message = state.message,
|
|
||||||
onRetry = onFinish
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show training overlay if processing
|
|
||||||
if (trainingState is TrainingState.Processing) {
|
if (trainingState is TrainingState.Processing) {
|
||||||
TrainingOverlay(trainingState = trainingState as TrainingState.Processing)
|
TrainingOverlay(trainingState = trainingState as TrainingState.Processing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Face Picker Dialog
|
|
||||||
showFacePickerDialog?.let { result ->
|
showFacePickerDialog?.let { result ->
|
||||||
FacePickerDialog ( // CHANGED
|
FacePickerDialog(
|
||||||
result = result,
|
result = result,
|
||||||
onDismiss = { showFacePickerDialog = null },
|
onDismiss = { showFacePickerDialog = null },
|
||||||
onFaceSelected = { faceIndex, croppedFaceBitmap ->
|
onFaceSelected = { faceIndex, croppedFaceBitmap ->
|
||||||
@@ -137,181 +115,32 @@ fun ScanResultsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name Input Dialog
|
|
||||||
if (showNameInputDialog) {
|
|
||||||
NameInputDialog(
|
|
||||||
onDismiss = { showNameInputDialog = false },
|
|
||||||
onConfirm = { name ->
|
|
||||||
showNameInputDialog = false
|
|
||||||
trainViewModel.createFaceModel(name)
|
|
||||||
},
|
|
||||||
trainingState = trainingState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog for entering person's name before training
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun NameInputDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (String) -> Unit,
|
|
||||||
trainingState: TrainingState
|
|
||||||
) {
|
|
||||||
var personName by remember { mutableStateOf("") }
|
|
||||||
val isError = trainingState is TrainingState.Error
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
if (trainingState !is TrainingState.Processing) {
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = if (isError) "Training Error" else "Who is this?",
|
|
||||||
style = MaterialTheme.typography.headlineSmall
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
if (isError) {
|
|
||||||
// Show error message
|
|
||||||
val error = trainingState as TrainingState.Error
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = error.message,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Enter the name of the person in these training images. This will help you find their photos later.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = personName,
|
|
||||||
onValueChange = { personName = it },
|
|
||||||
label = { Text("Person's Name") },
|
|
||||||
placeholder = { Text("e.g., John Doe") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = trainingState !is TrainingState.Processing,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
capitalization = KeyboardCapitalization.Words,
|
|
||||||
imeAction = ImeAction.Done
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
if (personName.isNotBlank()) {
|
|
||||||
onConfirm(personName.trim())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = { onConfirm(personName.trim()) },
|
|
||||||
enabled = personName.isNotBlank() && trainingState !is TrainingState.Processing
|
|
||||||
) {
|
|
||||||
if (trainingState is TrainingState.Processing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
Text(if (isError) "Try Again" else "Start Training")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
if (trainingState !is TrainingState.Processing) {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overlay shown during training process
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrainingOverlay(trainingState: TrainingState.Processing) {
|
private fun TrainingOverlay(trainingState: TrainingState.Processing) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f)),
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.7f)),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(32.dp).fillMaxWidth(0.9f),
|
||||||
.padding(32.dp)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
.fillMaxWidth(0.9f),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(24.dp),
|
modifier = Modifier.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(modifier = Modifier.size(64.dp), strokeWidth = 6.dp)
|
||||||
modifier = Modifier.size(64.dp),
|
Text("Creating Face Model", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
strokeWidth = 6.dp
|
Text(trainingState.stage, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Creating Face Model",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = trainingState.stage,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
if (trainingState.total > 0) {
|
if (trainingState.total > 0) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (trainingState.progress.toFloat() / trainingState.total.toFloat()).coerceIn(0f, 1f) },
|
progress = { (trainingState.progress.toFloat() / trainingState.total.toFloat()).coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
Text("${trainingState.progress} / ${trainingState.total}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(
|
|
||||||
text = "${trainingState.progress} / ${trainingState.total}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,31 +154,18 @@ private fun ProcessingView(progress: Int, total: Int) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(modifier = Modifier.size(64.dp), strokeWidth = 6.dp)
|
||||||
modifier = Modifier.size(64.dp),
|
|
||||||
strokeWidth = 6.dp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text("Analyzing images...", style = MaterialTheme.typography.titleMedium)
|
||||||
text = "Analyzing images...",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text("Detecting faces and checking for duplicates", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
text = "Detecting faces and checking for duplicates",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (progress.toFloat() / total.toFloat()).coerceIn(0f, 1f) },
|
progress = { (progress.toFloat() / total.toFloat()).coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.width(200.dp)
|
modifier = Modifier.width(200.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text("$progress / $total", style = MaterialTheme.typography.bodySmall)
|
||||||
text = "$progress / $total",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,25 +184,16 @@ private fun ImprovedResultsView(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Welcome Header
|
|
||||||
item {
|
item {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
modifier = Modifier.padding(16.dp)
|
Text("Analysis Complete!", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Analysis Complete!",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Review your images below. Tap 'Pick Face' on group photos to choose which person to train on, or 'Replace' to swap out any image.",
|
"Review your images below. Tap 'Pick Face' on group photos to choose which person to train on, or 'Replace' to swap out any image.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
@@ -394,7 +201,6 @@ private fun ImprovedResultsView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress Summary
|
|
||||||
item {
|
item {
|
||||||
ProgressSummaryCard(
|
ProgressSummaryCard(
|
||||||
totalImages = result.faceDetectionResults.size,
|
totalImages = result.faceDetectionResults.size,
|
||||||
@@ -404,40 +210,28 @@ private fun ImprovedResultsView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image List Header
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Text("Your Images (${result.faceDetectionResults.size})", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
text = "Your Images (${result.faceDetectionResults.size})",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image List with Actions
|
|
||||||
itemsIndexed(result.faceDetectionResults) { index, imageResult ->
|
itemsIndexed(result.faceDetectionResults) { index, imageResult ->
|
||||||
ImageResultCard(
|
ImageResultCard(
|
||||||
index = index + 1,
|
index = index + 1,
|
||||||
result = imageResult,
|
result = imageResult,
|
||||||
onReplace = { newUri ->
|
onReplace = { newUri -> onReplaceImage(imageResult.uri, newUri) },
|
||||||
onReplaceImage(imageResult.uri, newUri)
|
onSelectFace = if (imageResult.faceCount > 1) { { onSelectFaceFromMultiple(imageResult) } } else null,
|
||||||
},
|
|
||||||
onSelectFace = if (imageResult.faceCount > 1) {
|
|
||||||
{ onSelectFaceFromMultiple(imageResult) }
|
|
||||||
} else null,
|
|
||||||
trainViewModel = trainViewModel,
|
trainViewModel = trainViewModel,
|
||||||
isExcluded = trainViewModel.isImageExcluded(imageResult.uri)
|
isExcluded = trainViewModel.isImageExcluded(imageResult.uri)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation Issues (if any)
|
|
||||||
if (result.validationErrors.isNotEmpty()) {
|
if (result.validationErrors.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
ValidationIssuesCard(errors = result.validationErrors)
|
ValidationIssuesCard(errors = result.validationErrors, trainViewModel = trainViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action Button
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Button(
|
Button(
|
||||||
@@ -445,16 +239,10 @@ private fun ImprovedResultsView(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = result.isValid,
|
enabled = result.isValid,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = if (result.isValid)
|
containerColor = if (result.isValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(if (result.isValid) Icons.Default.CheckCircle else Icons.Default.Warning, contentDescription = null)
|
||||||
if (result.isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
if (result.isValid)
|
if (result.isValid)
|
||||||
@@ -471,19 +259,11 @@ private fun ImprovedResultsView(
|
|||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
modifier = Modifier.padding(12.dp),
|
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.size(20.dp))
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Info,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Tip: Use 'Replace' to swap problematic images, or 'Pick Face' to choose from group photos",
|
"Tip: Use 'Replace' to swap problematic images, or 'Pick Face' to choose from group photos",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
)
|
)
|
||||||
@@ -495,74 +275,30 @@ private fun ImprovedResultsView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProgressSummaryCard(
|
private fun ProgressSummaryCard(totalImages: Int, validImages: Int, requiredImages: Int, isValid: Boolean) {
|
||||||
totalImages: Int,
|
|
||||||
validImages: Int,
|
|
||||||
requiredImages: Int,
|
|
||||||
isValid: Boolean
|
|
||||||
) {
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (isValid)
|
containerColor = if (isValid) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
modifier = Modifier.padding(16.dp)
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
Text("Progress", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Progress",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
|
imageVector = if (isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (isValid)
|
tint = if (isValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
Row(
|
StatItem("Total", totalImages.toString(), MaterialTheme.colorScheme.onSurface)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
StatItem("Valid", validImages.toString(), if (validImages >= requiredImages) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
StatItem("Need", requiredImages.toString(), MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
|
||||||
) {
|
|
||||||
StatItem(
|
|
||||||
label = "Total",
|
|
||||||
value = totalImages.toString(),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
StatItem(
|
|
||||||
label = "Valid",
|
|
||||||
value = validImages.toString(),
|
|
||||||
color = if (validImages >= requiredImages)
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
StatItem(
|
|
||||||
label = "Need",
|
|
||||||
value = requiredImages.toString(),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (validImages.toFloat() / requiredImages.toFloat()).coerceIn(0f, 1f) },
|
progress = { (validImages.toFloat() / requiredImages.toFloat()).coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -575,17 +311,8 @@ private fun ProgressSummaryCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun StatItem(label: String, value: String, color: Color) {
|
private fun StatItem(label: String, value: String, color: Color) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(value, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = color)
|
||||||
text = value,
|
Text(label, style = MaterialTheme.typography.bodySmall, color = color.copy(alpha = 0.7f))
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = color
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = color.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,11 +325,7 @@ private fun ImageResultCard(
|
|||||||
trainViewModel: TrainViewModel,
|
trainViewModel: TrainViewModel,
|
||||||
isExcluded: Boolean
|
isExcluded: Boolean
|
||||||
) {
|
) {
|
||||||
val photoPickerLauncher = rememberLauncherForActivityResult(
|
val photoPickerLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.PickVisualMedia()) { uri -> uri?.let { onReplace(it) } }
|
||||||
contract = ActivityResultContracts.PickVisualMedia()
|
|
||||||
) { uri ->
|
|
||||||
uri?.let { onReplace(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = when {
|
val status = when {
|
||||||
isExcluded -> ImageStatus.EXCLUDED
|
isExcluded -> ImageStatus.EXCLUDED
|
||||||
@@ -624,73 +347,42 @@ private fun ImageResultCard(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
// Image Number Badge
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.size(40.dp).background(
|
||||||
.size(40.dp)
|
color = when (status) {
|
||||||
.background(
|
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
||||||
color = when (status) {
|
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
||||||
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
ImageStatus.EXCLUDED -> MaterialTheme.colorScheme.outline
|
||||||
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
else -> MaterialTheme.colorScheme.error
|
||||||
ImageStatus.EXCLUDED -> MaterialTheme.colorScheme.outline
|
},
|
||||||
else -> MaterialTheme.colorScheme.error
|
shape = CircleShape
|
||||||
},
|
),
|
||||||
shape = CircleShape
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(index.toString(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
text = index.toString(),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumbnail
|
|
||||||
if (result.croppedFaceBitmap != null) {
|
if (result.croppedFaceBitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = result.croppedFaceBitmap.asImageBitmap(),
|
bitmap = result.croppedFaceBitmap.asImageBitmap(),
|
||||||
contentDescription = "Face",
|
contentDescription = "Face",
|
||||||
modifier = Modifier
|
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)).border(
|
||||||
.size(64.dp)
|
BorderStroke(2.dp, when (status) {
|
||||||
.clip(RoundedCornerShape(8.dp))
|
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
||||||
.border(
|
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
||||||
BorderStroke(
|
ImageStatus.EXCLUDED -> MaterialTheme.colorScheme.outline
|
||||||
2.dp,
|
else -> MaterialTheme.colorScheme.error
|
||||||
when (status) {
|
}),
|
||||||
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
RoundedCornerShape(8.dp)
|
||||||
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
),
|
||||||
ImageStatus.EXCLUDED -> MaterialTheme.colorScheme.outline
|
|
||||||
else -> MaterialTheme.colorScheme.error
|
|
||||||
}
|
|
||||||
),
|
|
||||||
RoundedCornerShape(8.dp)
|
|
||||||
),
|
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AsyncImage(
|
AsyncImage(model = result.uri, contentDescription = "Original image", modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop)
|
||||||
model = result.uri,
|
|
||||||
contentDescription = "Original image",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status and Info
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = when (status) {
|
imageVector = when (status) {
|
||||||
@@ -721,97 +413,48 @@ private fun ImageResultCard(
|
|||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text(result.uri.lastPathSegment ?: "Unknown", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
Text(
|
|
||||||
text = result.uri.lastPathSegment ?: "Unknown",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action Buttons
|
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.End,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
// Select Face button (for multiple faces, not excluded)
|
|
||||||
if (onSelectFace != null && !isExcluded) {
|
if (onSelectFace != null && !isExcluded) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onSelectFace,
|
onClick = onSelectFace,
|
||||||
modifier = Modifier.height(32.dp),
|
modifier = Modifier.height(32.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.tertiary),
|
||||||
contentColor = MaterialTheme.colorScheme.tertiary
|
|
||||||
),
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.Face, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
Icons.Default.Face,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Pick Face", style = MaterialTheme.typography.bodySmall)
|
Text("Pick Face", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace button (not for excluded)
|
|
||||||
if (!isExcluded) {
|
if (!isExcluded) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = { photoPickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||||
photoPickerLauncher.launch(
|
|
||||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.height(32.dp),
|
modifier = Modifier.height(32.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
Icons.Default.Refresh,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Replace", style = MaterialTheme.typography.bodySmall)
|
Text("Replace", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude/Include button
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isExcluded) {
|
if (isExcluded) trainViewModel.includeImage(result.uri) else trainViewModel.excludeImage(result.uri)
|
||||||
trainViewModel.includeImage(result.uri)
|
|
||||||
} else {
|
|
||||||
trainViewModel.excludeImage(result.uri)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.height(32.dp),
|
modifier = Modifier.height(32.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = if (isExcluded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error),
|
||||||
contentColor = if (isExcluded)
|
border = BorderStroke(1.dp, if (isExcluded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
),
|
|
||||||
border = BorderStroke(
|
|
||||||
1.dp,
|
|
||||||
if (isExcluded)
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(if (isExcluded) Icons.Default.Add else Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
if (isExcluded) Icons.Default.Add else Icons.Default.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(if (isExcluded) "Include" else "Exclude", style = MaterialTheme.typography.bodySmall)
|
||||||
if (isExcluded) "Include" else "Exclude",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,30 +462,16 @@ private fun ImageResultCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ValidationIssuesCard(errors: List<TrainingSanityChecker.ValidationError>) {
|
private fun ValidationIssuesCard(errors: List<TrainingSanityChecker.ValidationError>, trainViewModel: TrainViewModel) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f))
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error)
|
||||||
Icons.Default.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text("Issues Found (${errors.size})", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error)
|
||||||
text = "Issues Found (${errors.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f))
|
HorizontalDivider(color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f))
|
||||||
@@ -850,35 +479,41 @@ private fun ValidationIssuesCard(errors: List<TrainingSanityChecker.ValidationEr
|
|||||||
errors.forEach { error ->
|
errors.forEach { error ->
|
||||||
when (error) {
|
when (error) {
|
||||||
is TrainingSanityChecker.ValidationError.NoFaceDetected -> {
|
is TrainingSanityChecker.ValidationError.NoFaceDetected -> {
|
||||||
Text(
|
Text("• ${error.uris.size} image(s) without detected faces - use Replace button", style = MaterialTheme.typography.bodyMedium)
|
||||||
text = "• ${error.uris.size} image(s) without detected faces - use Replace button",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is TrainingSanityChecker.ValidationError.MultipleFacesDetected -> {
|
is TrainingSanityChecker.ValidationError.MultipleFacesDetected -> {
|
||||||
Text(
|
Text("• ${error.uri.lastPathSegment} has ${error.faceCount} faces - use Pick Face button", style = MaterialTheme.typography.bodyMedium)
|
||||||
text = "• ${error.uri.lastPathSegment} has ${error.faceCount} faces - use Pick Face button",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is TrainingSanityChecker.ValidationError.DuplicateImages -> {
|
is TrainingSanityChecker.ValidationError.DuplicateImages -> {
|
||||||
Text(
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
text = "• ${error.groups.size} duplicate image group(s) - replace duplicates",
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.bodyMedium
|
Text("• ${error.groups.size} duplicate group(s) found", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||||
)
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
error.groups.forEach { group ->
|
||||||
|
group.images.drop(1).forEach { uri ->
|
||||||
|
trainViewModel.excludeImage(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary),
|
||||||
|
modifier = Modifier.height(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Drop All", style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("${error.groups.sumOf { it.images.size - 1 }} duplicate images will be excluded", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is TrainingSanityChecker.ValidationError.InsufficientImages -> {
|
is TrainingSanityChecker.ValidationError.InsufficientImages -> {
|
||||||
Text(
|
Text("• Need ${error.required} valid images, currently have ${error.available}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
text = "• Need ${error.required} valid images, currently have ${error.available}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is TrainingSanityChecker.ValidationError.ImageLoadError -> {
|
is TrainingSanityChecker.ValidationError.ImageLoadError -> {
|
||||||
Text(
|
Text("• Failed to load ${error.uri.lastPathSegment} - use Replace button", style = MaterialTheme.typography.bodyMedium)
|
||||||
text = "• Failed to load ${error.uri.lastPathSegment} - use Replace button",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,35 +522,13 @@ private fun ValidationIssuesCard(errors: List<TrainingSanityChecker.ValidationEr
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorView(
|
private fun ErrorView(message: String, onRetry: () -> Unit) {
|
||||||
message: String,
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||||
onRetry: () -> Unit
|
Icon(imageVector = Icons.Default.Close, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error)
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(64.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text("Error", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
text = "Error",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(message, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center)
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Button(onClick = onRetry) {
|
Button(onClick = onRetry) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ class TrainViewModel @Inject constructor(
|
|||||||
personInfo = PersonInfo(name, dateOfBirth, relationship)
|
personInfo = PersonInfo(name, dateOfBirth, relationship)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored person info
|
||||||
|
*/
|
||||||
|
fun getPersonInfo(): PersonInfo? = personInfo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exclude an image from training
|
* Exclude an image from training
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.placeholder.sherpai2.ui.trainingprep
|
package com.placeholder.sherpai2.ui.trainingprep
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -19,21 +18,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
||||||
/**
|
|
||||||
* CLEANED TrainingScreen - No duplicate header
|
|
||||||
*
|
|
||||||
* Removed:
|
|
||||||
* - Scaffold wrapper (lines 46-55)
|
|
||||||
* - TopAppBar (was creating banner)
|
|
||||||
* - "Train New Person" title (MainScreen shows it)
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Person info capture (name, DOB, relationship)
|
|
||||||
* - Onboarding cards
|
|
||||||
* - Beautiful gradient design
|
|
||||||
* - Clear call to action
|
|
||||||
* - Scrollable on small screens
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrainingScreen(
|
fun TrainingScreen(
|
||||||
onSelectImages: () -> Unit,
|
onSelectImages: () -> Unit,
|
||||||
@@ -49,52 +33,36 @@ fun TrainingScreen(
|
|||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
) {
|
) {
|
||||||
|
// ✅ TIGHTENED Hero section
|
||||||
|
CompactHeroCard()
|
||||||
|
|
||||||
// Hero section with gradient
|
|
||||||
HeroCard()
|
|
||||||
|
|
||||||
// How it works section
|
|
||||||
HowItWorksSection()
|
HowItWorksSection()
|
||||||
|
|
||||||
// Requirements section
|
|
||||||
RequirementsCard()
|
RequirementsCard()
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
// Main CTA button
|
// Main CTA
|
||||||
Button(
|
Button(
|
||||||
onClick = { showInfoDialog = true },
|
onClick = { showInfoDialog = true },
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().height(60.dp),
|
||||||
.fillMaxWidth()
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
|
||||||
.height(60.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.PersonAdd, contentDescription = null, modifier = Modifier.size(24.dp))
|
||||||
Icons.Default.PersonAdd,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(
|
Text("Start Training", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
"Start Training",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Person info dialog
|
// ✅ PersonInfo dialog BEFORE photo selection (CORRECT!)
|
||||||
if (showInfoDialog) {
|
if (showInfoDialog) {
|
||||||
BeautifulPersonInfoDialog(
|
BeautifulPersonInfoDialog(
|
||||||
onDismiss = { showInfoDialog = false },
|
onDismiss = { showInfoDialog = false },
|
||||||
onConfirm = { name, dob, relationship ->
|
onConfirm = { name, dob, relationship ->
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
// Store person info in ViewModel
|
|
||||||
trainViewModel.setPersonInfo(name, dob, relationship)
|
trainViewModel.setPersonInfo(name, dob, relationship)
|
||||||
onSelectImages()
|
onSelectImages()
|
||||||
}
|
}
|
||||||
@@ -103,58 +71,54 @@ fun TrainingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroCard() {
|
private fun CompactHeroCard() {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
shape = RoundedCornerShape(20.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.horizontalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
MaterialTheme.colorScheme.primaryContainer,
|
MaterialTheme.colorScheme.primaryContainer,
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.padding(20.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(
|
// Compact icon
|
||||||
modifier = Modifier.padding(24.dp),
|
Surface(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
shape = RoundedCornerShape(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
modifier = Modifier.size(56.dp)
|
||||||
) {
|
) {
|
||||||
Surface(
|
Box(contentAlignment = Alignment.Center) {
|
||||||
shape = RoundedCornerShape(20.dp),
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Icons.Default.Face,
|
||||||
shadowElevation = 8.dp,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(80.dp)
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
Box(contentAlignment = Alignment.Center) {
|
)
|
||||||
Icon(
|
|
||||||
Icons.Default.Face,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(48.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text inline
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
"Face Recognition Training",
|
"Face Recognition",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Train the AI to recognize someone in your photos",
|
"Train AI to find someone in your photos",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,54 +129,20 @@ private fun HeroCard() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun HowItWorksSection() {
|
private fun HowItWorksSection() {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text("How It Works", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
"How It Works",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
StepCard(
|
StepCard(1, Icons.Default.Info, "Enter Person Details", "Name, birthday, and relationship")
|
||||||
number = 1,
|
StepCard(2, Icons.Default.PhotoLibrary, "Select Training Photos", "Choose 20-30 photos of the person")
|
||||||
icon = Icons.Default.Info,
|
StepCard(3, Icons.Default.SmartToy, "AI Training", "We'll create a recognition model")
|
||||||
title = "Enter Person Details",
|
StepCard(4, Icons.Default.AutoFixHigh, "Auto-Tag Photos", "Find this person across your library")
|
||||||
description = "Name, birthday, and relationship"
|
|
||||||
)
|
|
||||||
|
|
||||||
StepCard(
|
|
||||||
number = 2,
|
|
||||||
icon = Icons.Default.PhotoLibrary,
|
|
||||||
title = "Select Training Photos",
|
|
||||||
description = "Choose 20-30 photos of the person"
|
|
||||||
)
|
|
||||||
|
|
||||||
StepCard(
|
|
||||||
number = 3,
|
|
||||||
icon = Icons.Default.SmartToy,
|
|
||||||
title = "AI Training",
|
|
||||||
description = "We'll create a recognition model"
|
|
||||||
)
|
|
||||||
|
|
||||||
StepCard(
|
|
||||||
number = 4,
|
|
||||||
icon = Icons.Default.AutoFixHigh,
|
|
||||||
title = "Auto-Tag Photos",
|
|
||||||
description = "Find this person across your library"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StepCard(
|
private fun StepCard(number: Int, icon: ImageVector, title: String, description: String) {
|
||||||
number: Int,
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
description: String
|
|
||||||
) {
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -220,45 +150,22 @@ private fun StepCard(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Number circle
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text("$number", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimary)
|
||||||
"$number",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
description,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,75 +175,31 @@ private fun StepCard(
|
|||||||
private fun RequirementsCard() {
|
private fun RequirementsCard() {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)),
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
modifier = Modifier.padding(20.dp),
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp))
|
||||||
) {
|
Text("Best Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Best Results",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RequirementItem(
|
RequirementItem(Icons.Default.PhotoCamera, "20-30 photos minimum")
|
||||||
icon = Icons.Default.PhotoCamera,
|
RequirementItem(Icons.Default.Face, "Clear, well-lit face photos")
|
||||||
text = "20-30 photos minimum"
|
RequirementItem(Icons.Default.Diversity1, "Variety of angles & expressions")
|
||||||
)
|
RequirementItem(Icons.Default.HighQuality, "Good quality images")
|
||||||
|
|
||||||
RequirementItem(
|
|
||||||
icon = Icons.Default.Face,
|
|
||||||
text = "Clear, well-lit face photos"
|
|
||||||
)
|
|
||||||
|
|
||||||
RequirementItem(
|
|
||||||
icon = Icons.Default.Diversity1,
|
|
||||||
text = "Variety of angles & expressions"
|
|
||||||
)
|
|
||||||
|
|
||||||
RequirementItem(
|
|
||||||
icon = Icons.Default.HighQuality,
|
|
||||||
text = "Good quality images"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RequirementItem(
|
private fun RequirementItem(icon: ImageVector, text: String) {
|
||||||
icon: ImageVector,
|
|
||||||
text: String
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||||
icon,
|
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user