Compare commits
4 Commits
0f7f4a4201
...
dev-backen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6734c343cc | ||
|
|
22c25d5ced | ||
|
|
dba64b89b6 | ||
|
|
3f15bfabc1 |
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
61
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
61
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -72,4 +72,10 @@ dependencies {
|
||||
|
||||
// Coil Images
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// ML Kit
|
||||
implementation(libs.mlkit.face.detection)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,36 +15,27 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
|
||||
import com.placeholder.sherpai2.ui.imagedetail.viewmodel.ImageDetailViewModel
|
||||
import com.placeholder.sherpai2.ui.search.SearchScreen
|
||||
import com.placeholder.sherpai2.ui.search.SearchViewModel
|
||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
||||
import com.placeholder.sherpai2.ui.presentation.MainScreen
|
||||
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* Centralized string-based navigation routes.
|
||||
*/
|
||||
object Routes {
|
||||
const val Search = "search"
|
||||
const val ImageDetail = "image_detail"
|
||||
}
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var imageRepository: ImageRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val mediaPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// Determine storage permission based on Android version
|
||||
val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
@@ -52,72 +43,44 @@ class MainActivity : ComponentActivity() {
|
||||
SherpAI2Theme {
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(this, mediaPermission) ==
|
||||
ContextCompat.checkSelfPermission(this, storagePermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
// Track ingestion completion
|
||||
var imagesIngested by remember { mutableStateOf(false) }
|
||||
|
||||
// Launcher for permission request
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasPermission) permissionLauncher.launch(mediaPermission)
|
||||
) { granted ->
|
||||
hasPermission = granted
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
AppNavigation()
|
||||
// Trigger ingestion once permission is granted
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) {
|
||||
// Suspend until ingestion completes
|
||||
imageRepository.ingestImages()
|
||||
imagesIngested = true
|
||||
} else {
|
||||
permissionLauncher.launch(storagePermission)
|
||||
}
|
||||
}
|
||||
|
||||
// Gate UI until permission granted AND ingestion completed
|
||||
if (hasPermission && imagesIngested) {
|
||||
MainScreen()
|
||||
} else {
|
||||
PermissionDeniedScreen()
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Please grant storage permission to continue.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.Search
|
||||
) {
|
||||
|
||||
// Search screen
|
||||
composable(Routes.Search) {
|
||||
val searchViewModel: SearchViewModel = hiltViewModel()
|
||||
|
||||
SearchScreen(
|
||||
searchViewModel = searchViewModel,
|
||||
onImageClick = { imageId ->
|
||||
navController.navigate("${Routes.ImageDetail}/$imageId")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Image detail screen
|
||||
composable(
|
||||
route = "${Routes.ImageDetail}/{imageId}",
|
||||
arguments = listOf(navArgument("imageId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val imageId = backStackEntry.arguments?.getString("imageId") ?: ""
|
||||
val detailViewModel: ImageDetailViewModel = hiltViewModel()
|
||||
|
||||
ImageDetailScreen(
|
||||
imageUri = imageId,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionDeniedScreen() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Please grant photo access to use the app.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
||||
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
@@ -53,4 +55,14 @@ interface ImageDao {
|
||||
start: Long,
|
||||
end: Long
|
||||
): List<ImageEntity>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM images ORDER BY capturedAt DESC LIMIT :limit")
|
||||
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
|
||||
|
||||
@Query("SELECT COUNT(*) > 0 FROM images WHERE sha256 = :sha256")
|
||||
suspend fun existsBySha256(sha256: String): Boolean
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insert(image: ImageEntity)
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.placeholder.sherpai2.data.photos
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class Photo(
|
||||
val id: Long,
|
||||
val uri: Uri,
|
||||
val title: String? = null,
|
||||
val size: Long,
|
||||
val dateModified: Int
|
||||
)
|
||||
|
||||
data class Album(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val photos: List<Photo>
|
||||
)
|
||||
|
||||
data class AlbumPhoto(
|
||||
val id: Int,
|
||||
val imageUrl: String,
|
||||
val description: String
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.placeholder.sherpai2.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.placeholder.sherpai2.data.photos.Photo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class PhotoRepository(private val context: Context) {
|
||||
|
||||
fun scanExternalStorage(): Result<List<Photo>> {
|
||||
// Best Practice: Use Environment.getExternalStorageDirectory()
|
||||
// only as a fallback or starting point for legacy support.
|
||||
val rootPath = Environment.getExternalStorageDirectory()
|
||||
|
||||
return runCatching {
|
||||
val photos = mutableListOf<Photo>()
|
||||
|
||||
if (rootPath.exists() && rootPath.isDirectory) {
|
||||
// walkTopDown is efficient but can throw AccessDeniedException
|
||||
rootPath.walkTopDown()
|
||||
.maxDepth(3) // Performance Best Practice: Don't scan the whole phone
|
||||
.filter { it.isFile && isImageFile(it.extension) }
|
||||
.forEach { file ->
|
||||
photos.add(mapFileToPhoto(file))
|
||||
}
|
||||
}
|
||||
photos
|
||||
}.onFailure { e ->
|
||||
Log.e("PhotoRepo", "Failed to scan filesystem", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapFileToPhoto(file: File): Photo {
|
||||
return Photo(
|
||||
id = file.path.hashCode().toLong(),
|
||||
uri = Uri.fromFile(file),
|
||||
title = file.name,
|
||||
size = file.length(),
|
||||
dateModified = (file.lastModified() / 1000).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
private fun isImageFile(ext: String) = listOf("jpg", "jpeg", "png").contains(ext.lowercase())
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.placeholder.sherpai2.di
|
||||
|
||||
import com.placeholder.sherpai2.data.repository.ImageRepositoryImpl
|
||||
|
||||
import com.placeholder.sherpai2.data.repository.TaggingRepositoryImpl
|
||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
||||
import com.placeholder.sherpai2.domain.repository.ImageRepositoryImpl
|
||||
import com.placeholder.sherpai2.domain.repository.TaggingRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.placeholder.sherpai2.domain
|
||||
|
||||
//fun getAllPhotos(context: Context): List<Photo> {
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.placeholder.sherpai2.domain
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class PhotoDuplicateScanner(private val context: Context) {
|
||||
|
||||
}
|
||||
@@ -28,4 +28,6 @@ interface ImageRepository {
|
||||
|
||||
fun getAllImages(): Flow<List<ImageWithEverything>>
|
||||
fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>>
|
||||
|
||||
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package com.placeholder.sherpai2.data.repository
|
||||
package com.placeholder.sherpai2.domain.repository
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import com.placeholder.sherpai2.data.local.dao.EventDao
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageDao
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageEventDao
|
||||
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity
|
||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
||||
import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
||||
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -15,50 +25,123 @@ class ImageRepositoryImpl @Inject constructor(
|
||||
private val imageDao: ImageDao,
|
||||
private val eventDao: EventDao,
|
||||
private val imageEventDao: ImageEventDao,
|
||||
private val aggregateDao: ImageAggregateDao
|
||||
private val aggregateDao: ImageAggregateDao,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ImageRepository {
|
||||
|
||||
override fun observeImage(imageId: String): Flow<com.placeholder.sherpai2.data.local.model.ImageWithEverything> {
|
||||
override fun observeImage(imageId: String): Flow<ImageWithEverything> {
|
||||
return aggregateDao.observeImageWithEverything(imageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest images from device.
|
||||
*
|
||||
* NOTE:
|
||||
* Actual MediaStore scanning is deliberately omitted here.
|
||||
* This function assumes images already exist in ImageDao.
|
||||
* Ingest all images from MediaStore.
|
||||
* Uses _ID and DATE_ADDED to ensure no image is skipped, even if DATE_TAKEN is identical.
|
||||
*/
|
||||
override suspend fun ingestImages() {
|
||||
// Step 1: fetch all images
|
||||
val images = imageDao.getImagesInRange(
|
||||
start = 0L,
|
||||
end = Long.MAX_VALUE
|
||||
)
|
||||
override suspend fun ingestImages(): Unit = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val imageList = mutableListOf<ImageEntity>()
|
||||
|
||||
// Step 2: auto-assign events by timestamp
|
||||
images.forEach { image ->
|
||||
val events = eventDao.findEventsForTimestamp(image.capturedAt)
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.DATE_TAKEN,
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
MediaStore.Images.Media.WIDTH,
|
||||
MediaStore.Images.Media.HEIGHT
|
||||
)
|
||||
|
||||
events.forEach { event ->
|
||||
imageEventDao.upsert(
|
||||
ImageEventEntity(
|
||||
imageId = image.imageId,
|
||||
eventId = event.eventId,
|
||||
source = "AUTO",
|
||||
override = false
|
||||
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC"
|
||||
|
||||
context.contentResolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
sortOrder
|
||||
)?.use { cursor ->
|
||||
|
||||
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
val dateTakenCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
|
||||
val dateAddedCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
|
||||
val widthCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH)
|
||||
val heightCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idCol)
|
||||
val displayName = cursor.getString(nameCol)
|
||||
val dateTaken = cursor.getLong(dateTakenCol)
|
||||
val dateAdded = cursor.getLong(dateAddedCol)
|
||||
val width = cursor.getInt(widthCol)
|
||||
val height = cursor.getInt(heightCol)
|
||||
|
||||
val contentUri: Uri = ContentUris.withAppendedId(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
|
||||
)
|
||||
)
|
||||
|
||||
val sha256 = computeSHA256(contentUri)
|
||||
if (sha256 == null) {
|
||||
Log.w("ImageRepository", "Skipped image: $displayName (cannot read bytes)")
|
||||
continue
|
||||
}
|
||||
|
||||
val imageEntity = ImageEntity(
|
||||
imageId = UUID.randomUUID().toString(),
|
||||
imageUri = contentUri.toString(),
|
||||
sha256 = sha256,
|
||||
capturedAt = if (dateTaken > 0) dateTaken else dateAdded * 1000,
|
||||
ingestedAt = System.currentTimeMillis(),
|
||||
width = width,
|
||||
height = height,
|
||||
source = "CAMERA" // or SCREENSHOT / IMPORTED
|
||||
)
|
||||
|
||||
imageList += imageEntity
|
||||
Log.i("ImageRepository", "Processing image: $displayName, SHA256: $sha256")
|
||||
}
|
||||
}
|
||||
|
||||
if (imageList.isNotEmpty()) {
|
||||
imageDao.insertImages(imageList)
|
||||
Log.i("ImageRepository", "Ingested ${imageList.size} images")
|
||||
} else {
|
||||
Log.i("ImageRepository", "No images found on device")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageRepository", "Error ingesting images", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllImages(): Flow<List<com.placeholder.sherpai2.data.local.model.ImageWithEverything>> {
|
||||
/**
|
||||
* Compute SHA256 from a MediaStore Uri safely.
|
||||
*/
|
||||
private fun computeSHA256(uri: Uri): String? {
|
||||
return try {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (input.read(buffer).also { read = it } > 0) {
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
} ?: return null
|
||||
digest.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageRepository", "Failed SHA256 for $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllImages(): Flow<List<ImageWithEverything>> {
|
||||
return aggregateDao.observeAllImagesWithEverything()
|
||||
}
|
||||
|
||||
override fun findImagesByTag(tag: String): Flow<List<com.placeholder.sherpai2.data.local.model.ImageWithEverything>> {
|
||||
// Assuming aggregateDao can filter images by tag
|
||||
override fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>> {
|
||||
return aggregateDao.observeImagesWithTag(tag)
|
||||
}
|
||||
|
||||
override fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>> {
|
||||
return imageDao.getRecentImages(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.placeholder.sherpai2.ui.devscreens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun DummyScreen(label: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
@@ -5,76 +5,39 @@ import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* AppDestinations
|
||||
* Drawer-only metadata.
|
||||
*
|
||||
* Centralized definition of all top-level screens.
|
||||
* This avoids stringly-typed navigation.
|
||||
* These objects:
|
||||
* - Drive the drawer UI
|
||||
* - Provide labels and icons
|
||||
* - Map cleanly to navigation routes
|
||||
*/
|
||||
sealed class AppDestinations(
|
||||
val route: String,
|
||||
val icon: ImageVector,
|
||||
val label: String
|
||||
) {
|
||||
object Tour : AppDestinations(AppRoutes.TOUR, Icons.Default.PhotoLibrary, "Tour")
|
||||
object Search : AppDestinations(AppRoutes.SEARCH, Icons.Default.Search, "Search")
|
||||
object Models : AppDestinations(AppRoutes.MODELS, Icons.Default.Layers, "Models")
|
||||
object Inventory : AppDestinations(AppRoutes.INVENTORY, Icons.Default.Inventory2, "Inv")
|
||||
object Train : AppDestinations(AppRoutes.TRAIN, Icons.Default.TrackChanges, "Train")
|
||||
object Tags : AppDestinations(AppRoutes.TAGS, Icons.Default.LocalOffer, "Tags")
|
||||
|
||||
object Tour : AppDestinations(
|
||||
route = "tour",
|
||||
icon = Icons.Default.PhotoLibrary,
|
||||
label = "Tour"
|
||||
)
|
||||
object ImageDetails : AppDestinations(AppRoutes.IMAGE_DETAIL, Icons.Default.LocalOffer, "IMAGE_DETAIL")
|
||||
|
||||
object Search : AppDestinations(
|
||||
route = "search",
|
||||
icon = Icons.Default.Search,
|
||||
label = "Search"
|
||||
)
|
||||
|
||||
object Models : AppDestinations(
|
||||
route = "models",
|
||||
icon = Icons.Default.Layers,
|
||||
label = "Models"
|
||||
)
|
||||
|
||||
object Inventory : AppDestinations(
|
||||
route = "inventory",
|
||||
icon = Icons.Default.Inventory2,
|
||||
label = "Inventory"
|
||||
)
|
||||
|
||||
object Train : AppDestinations(
|
||||
route = "train",
|
||||
icon = Icons.Default.TrackChanges,
|
||||
label = "Train"
|
||||
)
|
||||
|
||||
object Tags : AppDestinations(
|
||||
route = "tags",
|
||||
icon = Icons.Default.LocalOffer,
|
||||
label = "Tags"
|
||||
)
|
||||
|
||||
object Upload : AppDestinations(
|
||||
route = "upload",
|
||||
icon = Icons.Default.CloudUpload,
|
||||
label = "Upload"
|
||||
)
|
||||
|
||||
object Settings : AppDestinations(
|
||||
route = "settings",
|
||||
icon = Icons.Default.Settings,
|
||||
label = "Settings"
|
||||
)
|
||||
object Upload : AppDestinations(AppRoutes.UPLOAD, Icons.Default.CloudUpload, "Upload")
|
||||
object Settings : AppDestinations(AppRoutes.SETTINGS, Icons.Default.Settings, "Settings")
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer grouping
|
||||
*/
|
||||
val mainDrawerItems = listOf(
|
||||
AppDestinations.Tour,
|
||||
AppDestinations.Search,
|
||||
AppDestinations.Models,
|
||||
AppDestinations.Inventory,
|
||||
AppDestinations.Train,
|
||||
AppDestinations.Tags
|
||||
AppDestinations.Tags,
|
||||
AppDestinations.ImageDetails
|
||||
)
|
||||
|
||||
val utilityDrawerItems = listOf(
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.placeholder.sherpai2.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.placeholder.sherpai2.ui.devscreens.DummyScreen
|
||||
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
|
||||
import com.placeholder.sherpai2.ui.search.SearchScreen
|
||||
import com.placeholder.sherpai2.ui.search.SearchViewModel
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import com.placeholder.sherpai2.ui.tour.TourViewModel
|
||||
import com.placeholder.sherpai2.ui.tour.TourScreen
|
||||
import com.placeholder.sherpai2.ui.trainingprep.ImageSelectorScreen
|
||||
import com.placeholder.sherpai2.ui.trainingprep.TrainingScreen
|
||||
import com.placeholder.sherpai2.ui.navigation.AppRoutes
|
||||
import com.placeholder.sherpai2.ui.navigation.AppRoutes.ScanResultsScreen
|
||||
import com.placeholder.sherpai2.ui.trainingprep.ScanningState
|
||||
import com.placeholder.sherpai2.ui.trainingprep.TrainViewModel
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.placeholder.sherpai2.ui.trainingprep.ScanResultsScreen
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = AppRoutes.SEARCH,
|
||||
modifier = modifier
|
||||
) {
|
||||
|
||||
/** SEARCH SCREEN **/
|
||||
composable(AppRoutes.SEARCH) {
|
||||
val searchViewModel: SearchViewModel = hiltViewModel()
|
||||
SearchScreen(
|
||||
searchViewModel = searchViewModel,
|
||||
onImageClick = { imageUri ->
|
||||
// Encode the URI to safely pass as argument
|
||||
val encodedUri = URLEncoder.encode(imageUri, "UTF-8")
|
||||
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** IMAGE DETAIL SCREEN **/
|
||||
composable(
|
||||
route = "${AppRoutes.IMAGE_DETAIL}/{imageUri}",
|
||||
arguments = listOf(
|
||||
navArgument("imageUri") {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
|
||||
// Decode URI to restore original value
|
||||
val imageUri = backStackEntry.arguments?.getString("imageUri")
|
||||
?.let { URLDecoder.decode(it, "UTF-8") }
|
||||
?: error("imageUri missing from navigation")
|
||||
|
||||
ImageDetailScreen(
|
||||
imageUri = imageUri,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(AppRoutes.TOUR) {
|
||||
val tourViewModel: TourViewModel = hiltViewModel()
|
||||
TourScreen(
|
||||
tourViewModel = tourViewModel,
|
||||
onImageClick = { imageUri ->
|
||||
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$imageUri")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** TRAINING FLOW **/
|
||||
composable(AppRoutes.TRAIN) { entry ->
|
||||
val trainViewModel: TrainViewModel = hiltViewModel()
|
||||
val uiState by trainViewModel.uiState.collectAsState()
|
||||
|
||||
// Observe the result from the ImageSelector
|
||||
val selectedUris = entry.savedStateHandle.get<List<Uri>>("selected_image_uris")
|
||||
|
||||
// If we have new URIs and we are currently Idle, start scanning
|
||||
LaunchedEffect(selectedUris) {
|
||||
if (selectedUris != null && uiState is ScanningState.Idle) {
|
||||
trainViewModel.scanAndTagFaces(selectedUris)
|
||||
// Clear the handle so it doesn't re-trigger on configuration change
|
||||
entry.savedStateHandle.remove<List<Uri>>("selected_image_uris")
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState is ScanningState.Idle) {
|
||||
// Initial state: Show start button or prompt
|
||||
TrainingScreen(
|
||||
onSelectImages = { navController.navigate(AppRoutes.IMAGE_SELECTOR) }
|
||||
)
|
||||
} else {
|
||||
// Processing or Success state: Show the results screen
|
||||
ScanResultsScreen(
|
||||
state = uiState,
|
||||
onFinish = {
|
||||
navController.navigate(AppRoutes.SEARCH) {
|
||||
popUpTo(AppRoutes.TRAIN) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(AppRoutes.IMAGE_SELECTOR) {
|
||||
ImageSelectorScreen(
|
||||
onImagesSelected = { uris ->
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("selected_image_uris", uris)
|
||||
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** DUMMY SCREENS FOR OTHER DRAWER ITEMS **/
|
||||
//composable(AppRoutes.TOUR) { DummyScreen("Tour (stub)") }
|
||||
composable(AppRoutes.MODELS) { DummyScreen("Models (stub)") }
|
||||
composable(AppRoutes.INVENTORY) { DummyScreen("Inventory (stub)") }
|
||||
//composable(AppRoutes.TRAIN) { DummyScreen("Train (stub)") }
|
||||
composable(AppRoutes.TAGS) { DummyScreen("Tags (stub)") }
|
||||
composable(AppRoutes.UPLOAD) { DummyScreen("Upload (stub)") }
|
||||
composable(AppRoutes.SETTINGS) { DummyScreen("Settings (stub)") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.placeholder.sherpai2.ui.navigation
|
||||
|
||||
/**
|
||||
* Centralized list of navigation routes used by NavHost.
|
||||
*
|
||||
* This intentionally mirrors AppDestinations.route
|
||||
* but exists as a pure navigation concern.
|
||||
*
|
||||
* Why:
|
||||
* - Drawer UI ≠ Navigation system
|
||||
* - Keeps NavHost decoupled from icons / labels
|
||||
*/
|
||||
object AppRoutes {
|
||||
const val TOUR = "tour"
|
||||
const val SEARCH = "search"
|
||||
const val MODELS = "models"
|
||||
const val INVENTORY = "inv"
|
||||
const val TRAIN = "train"
|
||||
const val TAGS = "tags"
|
||||
const val UPLOAD = "upload"
|
||||
const val SETTINGS = "settings"
|
||||
const val IMAGE_DETAIL = "IMAGE_DETAIL"
|
||||
|
||||
const val CROP_SCREEN = "CROP_SCREEN"
|
||||
const val IMAGE_SELECTOR = "Image Selection"
|
||||
const val TRAINING_SCREEN = "TRAINING_SCREEN"
|
||||
|
||||
const val ScanResultsScreen = "First Scan Results"
|
||||
|
||||
|
||||
//const val IMAGE_DETAIL = "IMAGE_DETAIL"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.placeholder.sherpai2.ui.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.DividerDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import com.placeholder.sherpai2.ui.navigation.AppRoutes
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppDrawerContent(
|
||||
currentRoute: String?,
|
||||
onDestinationClicked: (String) -> Unit
|
||||
) {
|
||||
// Drawer sheet with fixed width
|
||||
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
|
||||
|
||||
// Header / Logo
|
||||
Text(
|
||||
"SherpAI Control Panel",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
Modifier.fillMaxWidth(),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
color = DividerDefaults.color
|
||||
)
|
||||
|
||||
// Main drawer items
|
||||
val mainItems = listOf(
|
||||
Triple(AppRoutes.SEARCH, "Search", Icons.Default.Search),
|
||||
Triple(AppRoutes.TOUR, "Tour", Icons.Default.Place),
|
||||
Triple(AppRoutes.MODELS, "Models", Icons.Default.ModelTraining),
|
||||
Triple(AppRoutes.INVENTORY, "Inventory", Icons.AutoMirrored.Filled.List),
|
||||
Triple(AppRoutes.TRAIN, "Train", Icons.Default.Train),
|
||||
Triple(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
mainItems.forEach { (route, label, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(label) },
|
||||
icon = { Icon(icon, contentDescription = label) },
|
||||
selected = route == currentRoute,
|
||||
onClick = { onDestinationClicked(route) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
thickness = DividerDefaults.Thickness
|
||||
)
|
||||
|
||||
// Utility items
|
||||
val utilityItems = listOf(
|
||||
Triple(AppRoutes.UPLOAD, "Upload", Icons.Default.UploadFile),
|
||||
Triple(AppRoutes.SETTINGS, "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
utilityItems.forEach { (route, label, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(label) },
|
||||
icon = { Icon(icon, contentDescription = label) },
|
||||
selected = route == currentRoute,
|
||||
onClick = { onDestinationClicked(route) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.placeholder.sherpai2.ui.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.placeholder.sherpai2.ui.navigation.AppNavHost
|
||||
import com.placeholder.sherpai2.ui.navigation.AppRoutes
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Navigation controller for NavHost
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Track current backstack entry to update top bar title dynamically
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route ?: AppRoutes.SEARCH
|
||||
|
||||
// Drawer content for navigation
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
AppDrawerContent(
|
||||
currentRoute = currentRoute,
|
||||
onDestinationClicked = { route ->
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
if (route != currentRoute) {
|
||||
navController.navigate(route) {
|
||||
// Avoid multiple copies of the same destination
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Main scaffold with top bar
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentRoute.replaceFirstChar { it.uppercase() }) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = "Open Drawer")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
AppNavHost(
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
||||
*/
|
||||
@Composable
|
||||
fun ImageGridItem(
|
||||
image: ImageEntity
|
||||
image: ImageEntity,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(image.imageUri),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// TourScreen.kt
|
||||
package com.placeholder.sherpai2.ui.tour
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
||||
|
||||
@Composable
|
||||
fun TourScreen(tourViewModel: TourViewModel = hiltViewModel(), onImageClick: (String) -> Unit) {
|
||||
val images by tourViewModel.recentImages.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header with image count
|
||||
Text(
|
||||
text = "Gallery (${images.size} images)",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp)
|
||||
) {
|
||||
items(images) { image ->
|
||||
ImageCard(image)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageCard(image: ImageWithEverything) {
|
||||
Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(4.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(text = image.tags.toString(), style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
// Tags row with placeholders if fewer than 3
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
val tags = image.tags.map { it.tagId } // adjust depending on your entity
|
||||
tags.forEach { tag ->
|
||||
TagComposable(tag)
|
||||
}
|
||||
repeat(3 - tags.size.coerceAtMost(3)) {
|
||||
TagComposable("") // empty placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagComposable(tag: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.height(24.dp)
|
||||
.widthIn(min = 40.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.shapes.small),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (tag.isNotBlank()) tag else " ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// TourViewModel.kt
|
||||
package com.placeholder.sherpai2.ui.tour
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
||||
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TourViewModel @Inject constructor(
|
||||
private val imageRepository: ImageRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// Expose recent images as StateFlow
|
||||
private val _recentImages = MutableStateFlow<List<ImageWithEverything>>(emptyList())
|
||||
val recentImages: StateFlow<List<ImageWithEverything>> = _recentImages.asStateFlow()
|
||||
|
||||
init {
|
||||
loadRecentImages()
|
||||
}
|
||||
|
||||
private fun loadRecentImages(limit: Int = 100) {
|
||||
viewModelScope.launch {
|
||||
imageRepository.getRecentImages(limit)
|
||||
.catch { e ->
|
||||
println("TourViewModel: error fetching images: $e")
|
||||
_recentImages.value = emptyList()
|
||||
}
|
||||
.collect { images ->
|
||||
println("TourViewModel: fetched ${images.size} images")
|
||||
_recentImages.value = images
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Helper class for detecting duplicate or near-duplicate images using perceptual hashing
|
||||
*/
|
||||
class DuplicateImageDetector(private val context: Context) {
|
||||
|
||||
data class DuplicateCheckResult(
|
||||
val hasDuplicates: Boolean,
|
||||
val duplicateGroups: List<DuplicateGroup>,
|
||||
val uniqueImageCount: Int
|
||||
)
|
||||
|
||||
data class DuplicateGroup(
|
||||
val images: List<Uri>,
|
||||
val similarity: Double
|
||||
)
|
||||
|
||||
private data class ImageHash(
|
||||
val uri: Uri,
|
||||
val hash: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Check for duplicate images in the provided list
|
||||
*/
|
||||
suspend fun checkForDuplicates(
|
||||
uris: List<Uri>,
|
||||
similarityThreshold: Double = 0.95
|
||||
): DuplicateCheckResult = withContext(Dispatchers.Default) {
|
||||
if (uris.size < 2) {
|
||||
return@withContext DuplicateCheckResult(
|
||||
hasDuplicates = false,
|
||||
duplicateGroups = emptyList(),
|
||||
uniqueImageCount = uris.size
|
||||
)
|
||||
}
|
||||
|
||||
// Compute perceptual hash for each image
|
||||
val imageHashes = uris.mapNotNull { uri ->
|
||||
try {
|
||||
val bitmap = loadBitmap(uri)
|
||||
bitmap?.let {
|
||||
val hash = computePerceptualHash(it)
|
||||
ImageHash(uri, hash)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Find duplicate groups
|
||||
val duplicateGroups = mutableListOf<DuplicateGroup>()
|
||||
val processed = mutableSetOf<Uri>()
|
||||
|
||||
for (i in imageHashes.indices) {
|
||||
if (imageHashes[i].uri in processed) continue
|
||||
|
||||
val currentGroup = mutableListOf(imageHashes[i].uri)
|
||||
|
||||
for (j in i + 1 until imageHashes.size) {
|
||||
if (imageHashes[j].uri in processed) continue
|
||||
|
||||
val similarity = calculateSimilarity(imageHashes[i].hash, imageHashes[j].hash)
|
||||
|
||||
if (similarity >= similarityThreshold) {
|
||||
currentGroup.add(imageHashes[j].uri)
|
||||
processed.add(imageHashes[j].uri)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup.size > 1) {
|
||||
duplicateGroups.add(
|
||||
DuplicateGroup(
|
||||
images = currentGroup,
|
||||
similarity = 1.0
|
||||
)
|
||||
)
|
||||
processed.addAll(currentGroup)
|
||||
}
|
||||
}
|
||||
|
||||
DuplicateCheckResult(
|
||||
hasDuplicates = duplicateGroups.isNotEmpty(),
|
||||
duplicateGroups = duplicateGroups,
|
||||
uniqueImageCount = uris.size - duplicateGroups.sumOf { it.images.size - 1 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute perceptual hash using difference hash (dHash) algorithm
|
||||
*/
|
||||
private fun computePerceptualHash(bitmap: Bitmap): Long {
|
||||
// Resize to 9x8
|
||||
val resized = Bitmap.createScaledBitmap(bitmap, 9, 8, false)
|
||||
|
||||
var hash = 0L
|
||||
var bitIndex = 0
|
||||
|
||||
for (y in 0 until 8) {
|
||||
for (x in 0 until 8) {
|
||||
val leftPixel = resized.getPixel(x, y)
|
||||
val rightPixel = resized.getPixel(x + 1, y)
|
||||
|
||||
val leftGray = toGrayscale(leftPixel)
|
||||
val rightGray = toGrayscale(rightPixel)
|
||||
|
||||
if (leftGray > rightGray) {
|
||||
hash = hash or (1L shl bitIndex)
|
||||
}
|
||||
bitIndex++
|
||||
}
|
||||
}
|
||||
|
||||
resized.recycle()
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB pixel to grayscale value
|
||||
*/
|
||||
private fun toGrayscale(pixel: Int): Int {
|
||||
val r = (pixel shr 16) and 0xFF
|
||||
val g = (pixel shr 8) and 0xFF
|
||||
val b = pixel and 0xFF
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity between two hashes
|
||||
*/
|
||||
private fun calculateSimilarity(hash1: Long, hash2: Long): Double {
|
||||
val xor = hash1 xor hash2
|
||||
val hammingDistance = xor.countOneBits()
|
||||
return 1.0 - (hammingDistance / 64.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bitmap from URI
|
||||
*/
|
||||
private fun loadBitmap(uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
BitmapFactory.decodeStream(inputStream)?.also {
|
||||
inputStream?.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Dialog for selecting a face from multiple detected faces
|
||||
*/
|
||||
@Composable
|
||||
fun FacePickerDialog(
|
||||
result: FaceDetectionHelper.FaceDetectionResult,
|
||||
onDismiss: () -> Unit,
|
||||
onFaceSelected: (Int, Bitmap) -> Unit // faceIndex, croppedFaceBitmap
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var selectedFaceIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var croppedFaces by remember { mutableStateOf<List<Bitmap>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
// Load and crop all faces
|
||||
LaunchedEffect(result) {
|
||||
isLoading = true
|
||||
croppedFaces = withContext(Dispatchers.IO) {
|
||||
val bitmap = loadBitmapFromUri(context, result.uri)
|
||||
bitmap?.let { bmp ->
|
||||
result.faceBounds.map { bounds ->
|
||||
cropFaceFromBitmap(bmp, bounds)
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
isLoading = false
|
||||
// Auto-select the first (largest) face
|
||||
if (croppedFaces.isNotEmpty()) {
|
||||
selectedFaceIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.9f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Pick a Face",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "${result.faceCount} faces detected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, "Close")
|
||||
}
|
||||
}
|
||||
|
||||
// Instruction
|
||||
Text(
|
||||
text = "Tap a face below to select it for training:",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
// Loading state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
// Original image with face boxes overlay
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
FaceOverlayImage(
|
||||
imageUri = result.uri,
|
||||
faceBounds = result.faceBounds,
|
||||
selectedFaceIndex = selectedFaceIndex,
|
||||
onFaceClick = { index ->
|
||||
selectedFaceIndex = index
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Face previews grid
|
||||
Text(
|
||||
text = "Preview (tap to select):",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
croppedFaces.forEachIndexed { index, faceBitmap ->
|
||||
FacePreviewCard(
|
||||
faceBitmap = faceBitmap,
|
||||
index = index,
|
||||
isSelected = selectedFaceIndex == index,
|
||||
onClick = { selectedFaceIndex = index },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedFaceIndex?.let { index ->
|
||||
if (index < croppedFaces.size) {
|
||||
onFaceSelected(index, croppedFaces[index])
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = selectedFaceIndex != null && !isLoading
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Use This Face")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image with interactive face boxes overlay
|
||||
*/
|
||||
@Composable
|
||||
private fun FaceOverlayImage(
|
||||
imageUri: Uri,
|
||||
faceBounds: List<Rect>,
|
||||
selectedFaceIndex: Int?,
|
||||
onFaceClick: (Int) -> Unit
|
||||
) {
|
||||
var imageSize by remember { mutableStateOf(Size.Zero) }
|
||||
var imageBounds by remember { mutableStateOf(Rect()) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Original image
|
||||
AsyncImage(
|
||||
model = imageUri,
|
||||
contentDescription = "Original image",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
onSuccess = { state ->
|
||||
val drawable = state.result.drawable
|
||||
imageBounds = Rect(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
}
|
||||
)
|
||||
|
||||
// Face boxes overlay
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (imageBounds.width() > 0 && imageBounds.height() > 0) {
|
||||
// Calculate scale to fit image in canvas
|
||||
val scaleX = size.width / imageBounds.width()
|
||||
val scaleY = size.height / imageBounds.height()
|
||||
val scale = minOf(scaleX, scaleY)
|
||||
|
||||
// Calculate offset to center image
|
||||
val scaledWidth = imageBounds.width() * scale
|
||||
val scaledHeight = imageBounds.height() * scale
|
||||
val offsetX = (size.width - scaledWidth) / 2
|
||||
val offsetY = (size.height - scaledHeight) / 2
|
||||
|
||||
faceBounds.forEachIndexed { index, bounds ->
|
||||
val isSelected = selectedFaceIndex == index
|
||||
|
||||
// Scale and position the face box
|
||||
val left = bounds.left * scale + offsetX
|
||||
val top = bounds.top * scale + offsetY
|
||||
val width = bounds.width() * scale
|
||||
val height = bounds.height() * scale
|
||||
|
||||
// Draw box
|
||||
drawRect(
|
||||
color = if (isSelected) Color(0xFF4CAF50) else Color(0xFF2196F3),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(width, height),
|
||||
style = Stroke(width = if (isSelected) 6f else 4f)
|
||||
)
|
||||
|
||||
// Draw semi-transparent fill for selected
|
||||
if (isSelected) {
|
||||
drawRect(
|
||||
color = Color(0xFF4CAF50).copy(alpha = 0.2f),
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(width, height)
|
||||
)
|
||||
}
|
||||
|
||||
// Draw face number label
|
||||
drawCircle(
|
||||
color = if (isSelected) Color(0xFF4CAF50) else Color(0xFF2196F3),
|
||||
radius = 20f * scale,
|
||||
center = Offset(left + 20f * scale, top + 20f * scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clickable areas for each face
|
||||
faceBounds.forEachIndexed { index, bounds ->
|
||||
if (imageBounds.width() > 0 && imageBounds.height() > 0) {
|
||||
val scaleX = imageSize.width / imageBounds.width()
|
||||
val scaleY = imageSize.height / imageBounds.height()
|
||||
val scale = minOf(scaleX, scaleY)
|
||||
|
||||
val scaledWidth = imageBounds.width() * scale
|
||||
val scaledHeight = imageBounds.height() * scale
|
||||
val offsetX = (imageSize.width - scaledWidth) / 2
|
||||
val offsetY = (imageSize.height - scaledHeight) / 2
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { onFaceClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update image size
|
||||
BoxWithConstraints {
|
||||
LaunchedEffect(constraints) {
|
||||
imageSize = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual face preview card
|
||||
*/
|
||||
@Composable
|
||||
private fun FacePreviewCard(
|
||||
faceBitmap: Bitmap,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface
|
||||
),
|
||||
border = if (isSelected)
|
||||
BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
|
||||
else
|
||||
BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = faceBitmap.asImageBitmap(),
|
||||
contentDescription = "Face ${index + 1}",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// Selected checkmark (only show when selected)
|
||||
if (isSelected) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Face number badge (always in top-right, small)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
shape = CircleShape,
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
|
||||
shadowElevation = 2.dp
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
modifier = Modifier.padding(6.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to load bitmap from URI
|
||||
*/
|
||||
private suspend fun loadBitmapFromUri(
|
||||
context: android.content.Context,
|
||||
uri: Uri
|
||||
): Bitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
BitmapFactory.decodeStream(inputStream)?.also {
|
||||
inputStream?.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to crop face from bitmap
|
||||
*/
|
||||
private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
|
||||
// Add 20% padding around the face
|
||||
val padding = (faceBounds.width() * 0.2f).toInt()
|
||||
|
||||
val left = (faceBounds.left - padding).coerceAtLeast(0)
|
||||
val top = (faceBounds.top - padding).coerceAtLeast(0)
|
||||
val right = (faceBounds.right + padding).coerceAtMost(bitmap.width)
|
||||
val bottom = (faceBounds.bottom + padding).coerceAtMost(bitmap.height)
|
||||
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
|
||||
return Bitmap.createBitmap(bitmap, left, top, width, height)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
@Composable
|
||||
fun FacePickerScreen(
|
||||
uri: Uri,
|
||||
faceBoxes: List<Rect>,
|
||||
onFaceSelected: (Rect) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("Multiple faces detected!", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Tap the person you want to train on.")
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 16.dp)) {
|
||||
// Main Image
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(uri),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
// Overlay Clickable Boxes
|
||||
// Note: In a production app, you'd need to map Rect coordinates
|
||||
// from the Bitmap scale to the UI View scale.
|
||||
Canvas(modifier = Modifier.fillMaxSize().clickable { /* Handle general tap */ }) {
|
||||
// Implementation of coordinate mapping goes here
|
||||
}
|
||||
|
||||
// Simplified: Just show the options as a list of crops if Canvas mapping is too complex for now
|
||||
LazyRow {
|
||||
items(faceBoxes) { box ->
|
||||
Card(modifier = Modifier.padding(8.dp).size(100.dp).clickable { onFaceSelected(box) }) {
|
||||
Text("Face ${faceBoxes.indexOf(box) + 1}", Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.mlkit.vision.face.FaceDetection
|
||||
import com.google.mlkit.vision.face.FaceDetectorOptions
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Helper class for detecting faces in images using ML Kit Face Detection
|
||||
*/
|
||||
class FaceDetectionHelper(private val context: Context) {
|
||||
|
||||
private val faceDetectorOptions = FaceDetectorOptions.Builder()
|
||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
|
||||
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
|
||||
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
|
||||
.setMinFaceSize(0.15f) // Detect faces that are at least 15% of image
|
||||
.build()
|
||||
|
||||
private val detector = FaceDetection.getClient(faceDetectorOptions)
|
||||
|
||||
data class FaceDetectionResult(
|
||||
val uri: Uri,
|
||||
val hasFace: Boolean,
|
||||
val faceCount: Int,
|
||||
val faceBounds: List<Rect> = emptyList(),
|
||||
val croppedFaceBitmap: Bitmap? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Detect faces in a single image
|
||||
*/
|
||||
suspend fun detectFacesInImage(uri: Uri): FaceDetectionResult {
|
||||
return try {
|
||||
val bitmap = loadBitmap(uri)
|
||||
if (bitmap == null) {
|
||||
return FaceDetectionResult(
|
||||
uri = uri,
|
||||
hasFace = false,
|
||||
faceCount = 0,
|
||||
errorMessage = "Failed to load image"
|
||||
)
|
||||
}
|
||||
|
||||
val inputImage = InputImage.fromBitmap(bitmap, 0)
|
||||
val faces = detector.process(inputImage).await()
|
||||
|
||||
val croppedFace = if (faces.isNotEmpty()) {
|
||||
// Crop the first detected face with some padding
|
||||
cropFaceFromBitmap(bitmap, faces[0].boundingBox)
|
||||
} else null
|
||||
|
||||
FaceDetectionResult(
|
||||
uri = uri,
|
||||
hasFace = faces.isNotEmpty(),
|
||||
faceCount = faces.size,
|
||||
faceBounds = faces.map { it.boundingBox },
|
||||
croppedFaceBitmap = croppedFace
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
FaceDetectionResult(
|
||||
uri = uri,
|
||||
hasFace = false,
|
||||
faceCount = 0,
|
||||
errorMessage = e.message ?: "Unknown error"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect faces in multiple images
|
||||
*/
|
||||
suspend fun detectFacesInImages(uris: List<Uri>): List<FaceDetectionResult> {
|
||||
return uris.map { uri ->
|
||||
detectFacesInImage(uri)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop face from bitmap with padding
|
||||
*/
|
||||
private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
|
||||
// Add 20% padding around the face
|
||||
val padding = (faceBounds.width() * 0.2f).toInt()
|
||||
|
||||
val left = (faceBounds.left - padding).coerceAtLeast(0)
|
||||
val top = (faceBounds.top - padding).coerceAtLeast(0)
|
||||
val right = (faceBounds.right + padding).coerceAtMost(bitmap.width)
|
||||
val bottom = (faceBounds.bottom + padding).coerceAtMost(bitmap.height)
|
||||
|
||||
val width = right - left
|
||||
val height = bottom - top
|
||||
|
||||
return Bitmap.createBitmap(bitmap, left, top, width, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bitmap from URI
|
||||
*/
|
||||
private fun loadBitmap(uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
BitmapFactory.decodeStream(inputStream)?.also {
|
||||
inputStream?.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
fun cleanup() {
|
||||
detector.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImageSelectorScreen(
|
||||
onImagesSelected: (List<Uri>) -> Unit
|
||||
) {
|
||||
//1. Persist state across configuration changes
|
||||
var selectedUris by rememberSaveable { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { uris ->
|
||||
// 2. Take first 10 and try to persist permissions
|
||||
val limitedUris = uris.take(10)
|
||||
selectedUris = limitedUris
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Select Training Photos") }) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
OutlinedCard(
|
||||
onClick = { launcher.launch(arrayOf("image/*")) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(Icons.Default.AddPhotoAlternate, contentDescription = null)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Select up to 10 images of the person")
|
||||
Text(
|
||||
text = "${selectedUris.size} / 10 selected",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (selectedUris.size == 10) MaterialTheme.colorScheme.error
|
||||
else if (selectedUris.isNotEmpty()) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Conditional rendering for empty state
|
||||
if (selectedUris.isEmpty()) {
|
||||
Box(Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("No images selected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
) {
|
||||
items(selectedUris, key = { it.toString() }) { uri ->
|
||||
Box(modifier = Modifier.padding(4.dp)) {
|
||||
AsyncImage(
|
||||
model = uri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
// 4. Ability to remove specific images
|
||||
Surface(
|
||||
onClick = { selectedUris = selectedUris - uri },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = selectedUris.isNotEmpty(),
|
||||
onClick = { onImagesSelected(selectedUris) }
|
||||
) {
|
||||
Text("Start Face Detection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScanResultsScreen(
|
||||
state: ScanningState,
|
||||
onFinish: () -> Unit,
|
||||
trainViewModel: TrainViewModel = hiltViewModel()
|
||||
) {
|
||||
var showFacePickerDialog by remember { mutableStateOf<FaceDetectionHelper.FaceDetectionResult?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Training Image Analysis") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when (state) {
|
||||
is ScanningState.Idle -> {
|
||||
// Should not happen
|
||||
}
|
||||
|
||||
is ScanningState.Processing -> {
|
||||
ProcessingView(
|
||||
progress = state.progress,
|
||||
total = state.total
|
||||
)
|
||||
}
|
||||
|
||||
is ScanningState.Success -> {
|
||||
ImprovedResultsView(
|
||||
result = state.sanityCheckResult,
|
||||
onContinue = onFinish,
|
||||
onRetry = onFinish,
|
||||
onReplaceImage = { oldUri, newUri ->
|
||||
trainViewModel.replaceImage(oldUri, newUri)
|
||||
},
|
||||
onSelectFaceFromMultiple = { result ->
|
||||
showFacePickerDialog = result
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ScanningState.Error -> {
|
||||
ErrorView(
|
||||
message = state.message,
|
||||
onRetry = onFinish
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Face Picker Dialog
|
||||
showFacePickerDialog?.let { result ->
|
||||
FacePickerDialog(
|
||||
result = result,
|
||||
onDismiss = { showFacePickerDialog = null },
|
||||
onFaceSelected = { faceIndex, croppedFaceBitmap ->
|
||||
trainViewModel.selectFaceFromImage(result.uri, faceIndex, croppedFaceBitmap)
|
||||
showFacePickerDialog = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessingView(progress: Int, total: Int) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(64.dp),
|
||||
strokeWidth = 6.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Analyzing images...",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Detecting faces and checking for duplicates",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (total > 0) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { (progress.toFloat() / total.toFloat()).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.width(200.dp)
|
||||
)
|
||||
Text(
|
||||
text = "$progress / $total",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImprovedResultsView(
|
||||
result: TrainingSanityChecker.SanityCheckResult,
|
||||
onContinue: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onReplaceImage: (Uri, Uri) -> Unit,
|
||||
onSelectFaceFromMultiple: (FaceDetectionHelper.FaceDetectionResult) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Welcome Header
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Analysis Complete!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress Summary
|
||||
item {
|
||||
ProgressSummaryCard(
|
||||
totalImages = result.faceDetectionResults.size,
|
||||
validImages = result.validImagesWithFaces.size,
|
||||
requiredImages = 10,
|
||||
isValid = result.isValid
|
||||
)
|
||||
}
|
||||
|
||||
// Image List Header
|
||||
item {
|
||||
Text(
|
||||
text = "Your Images (${result.faceDetectionResults.size})",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Image List with Actions
|
||||
itemsIndexed(result.faceDetectionResults) { index, imageResult ->
|
||||
ImageResultCard(
|
||||
index = index + 1,
|
||||
result = imageResult,
|
||||
onReplace = { newUri ->
|
||||
onReplaceImage(imageResult.uri, newUri)
|
||||
},
|
||||
onSelectFace = if (imageResult.faceCount > 1) {
|
||||
{ onSelectFaceFromMultiple(imageResult) }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
// Validation Issues (if any)
|
||||
if (result.validationErrors.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ValidationIssuesCard(errors = result.validationErrors)
|
||||
}
|
||||
}
|
||||
|
||||
// Action Button
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = if (result.isValid) onContinue else onRetry,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = result.isValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (result.isValid)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
if (result.isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
if (result.isValid)
|
||||
"Continue to Training (${result.validImagesWithFaces.size} images)"
|
||||
else
|
||||
"Fix ${result.validationErrors.size} Issue${if (result.validationErrors.size != 1) "s" else ""} to Continue"
|
||||
)
|
||||
}
|
||||
|
||||
if (!result.isValid) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.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))
|
||||
Text(
|
||||
text = "Tip: Use 'Replace' to swap problematic images, or 'Pick Face' to choose from group photos",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressSummaryCard(
|
||||
totalImages: Int,
|
||||
validImages: Int,
|
||||
requiredImages: Int,
|
||||
isValid: Boolean
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isValid)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Progress",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = if (isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = if (isValid)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
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))
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { (validImages.toFloat() / requiredImages.toFloat()).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = if (isValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatItem(label: String, value: String, color: Color) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageResultCard(
|
||||
index: Int,
|
||||
result: FaceDetectionHelper.FaceDetectionResult,
|
||||
onReplace: (Uri) -> Unit,
|
||||
onSelectFace: (() -> Unit)?
|
||||
) {
|
||||
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
) { uri ->
|
||||
uri?.let { onReplace(it) }
|
||||
}
|
||||
|
||||
val status = when {
|
||||
result.errorMessage != null -> ImageStatus.ERROR
|
||||
!result.hasFace -> ImageStatus.NO_FACE
|
||||
result.faceCount > 1 -> ImageStatus.MULTIPLE_FACES
|
||||
result.faceCount == 1 -> ImageStatus.VALID
|
||||
else -> ImageStatus.ERROR
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = when (status) {
|
||||
ImageStatus.VALID -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)
|
||||
else -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Image Number Badge
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = when (status) {
|
||||
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
||||
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = index.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Thumbnail
|
||||
if (result.croppedFaceBitmap != null) {
|
||||
Image(
|
||||
bitmap = result.croppedFaceBitmap.asImageBitmap(),
|
||||
contentDescription = "Face",
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
BorderStroke(
|
||||
2.dp,
|
||||
when (status) {
|
||||
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
||||
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
),
|
||||
RoundedCornerShape(8.dp)
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
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)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = when (status) {
|
||||
ImageStatus.VALID -> Icons.Default.CheckCircle
|
||||
ImageStatus.MULTIPLE_FACES -> Icons.Default.Info
|
||||
else -> Icons.Default.Warning
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = when (status) {
|
||||
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
|
||||
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
},
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = when (status) {
|
||||
ImageStatus.VALID -> "Face Detected"
|
||||
ImageStatus.MULTIPLE_FACES -> "Multiple Faces (${result.faceCount})"
|
||||
ImageStatus.NO_FACE -> "No Face Detected"
|
||||
ImageStatus.ERROR -> "Error"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
// Select Face button (for multiple faces)
|
||||
if (onSelectFace != null) {
|
||||
OutlinedButton(
|
||||
onClick = onSelectFace,
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Face,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Pick Face", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
// Replace button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Replace", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValidationIssuesCard(errors: List<TrainingSanityChecker.ValidationError>) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Issues Found (${errors.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f))
|
||||
|
||||
errors.forEach { error ->
|
||||
when (error) {
|
||||
is TrainingSanityChecker.ValidationError.NoFaceDetected -> {
|
||||
Text(
|
||||
text = "• ${error.uris.size} image(s) without detected faces - use Replace button",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
is TrainingSanityChecker.ValidationError.MultipleFacesDetected -> {
|
||||
Text(
|
||||
text = "• ${error.uri.lastPathSegment} has ${error.faceCount} faces - use Pick Face button",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
is TrainingSanityChecker.ValidationError.DuplicateImages -> {
|
||||
Text(
|
||||
text = "• ${error.groups.size} duplicate image group(s) - replace duplicates",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
is TrainingSanityChecker.ValidationError.InsufficientImages -> {
|
||||
Text(
|
||||
text = "• Need ${error.required} valid images, currently have ${error.available}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
is TrainingSanityChecker.ValidationError.ImageLoadError -> {
|
||||
Text(
|
||||
text = "• Failed to load ${error.uri.lastPathSegment} - use Replace button",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorView(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
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))
|
||||
Text(
|
||||
text = "Error",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Try Again")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ImageStatus {
|
||||
VALID,
|
||||
MULTIPLE_FACES,
|
||||
NO_FACE,
|
||||
ERROR
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class ScanningState {
|
||||
object Idle : ScanningState()
|
||||
data class Processing(val progress: Int, val total: Int) : ScanningState()
|
||||
data class Success(
|
||||
val sanityCheckResult: TrainingSanityChecker.SanityCheckResult
|
||||
) : ScanningState()
|
||||
data class Error(val message: String) : ScanningState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class TrainViewModel @Inject constructor(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val sanityChecker = TrainingSanityChecker(application)
|
||||
private val faceDetectionHelper = FaceDetectionHelper(application)
|
||||
|
||||
private val _uiState = MutableStateFlow<ScanningState>(ScanningState.Idle)
|
||||
val uiState: StateFlow<ScanningState> = _uiState.asStateFlow()
|
||||
|
||||
// Keep track of current images for replacements
|
||||
private var currentImageUris: List<Uri> = emptyList()
|
||||
|
||||
// Keep track of manual face selections (imageUri -> selectedFaceIndex)
|
||||
private val manualFaceSelections = mutableMapOf<Uri, ManualFaceSelection>()
|
||||
|
||||
data class ManualFaceSelection(
|
||||
val faceIndex: Int,
|
||||
val croppedFaceBitmap: Bitmap
|
||||
)
|
||||
|
||||
/**
|
||||
* Scan and validate images for training
|
||||
*/
|
||||
fun scanAndTagFaces(imageUris: List<Uri>) {
|
||||
currentImageUris = imageUris
|
||||
manualFaceSelections.clear()
|
||||
performScan(imageUris)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a single image and re-scan
|
||||
*/
|
||||
fun replaceImage(oldUri: Uri, newUri: Uri) {
|
||||
viewModelScope.launch {
|
||||
val updatedUris = currentImageUris.toMutableList()
|
||||
val index = updatedUris.indexOf(oldUri)
|
||||
|
||||
if (index != -1) {
|
||||
updatedUris[index] = newUri
|
||||
currentImageUris = updatedUris
|
||||
|
||||
// Remove manual selection for old URI if any
|
||||
manualFaceSelections.remove(oldUri)
|
||||
|
||||
// Re-scan all images
|
||||
performScan(currentImageUris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User manually selected a face from a multi-face image
|
||||
*/
|
||||
fun selectFaceFromImage(imageUri: Uri, faceIndex: Int, croppedFaceBitmap: Bitmap) {
|
||||
manualFaceSelections[imageUri] = ManualFaceSelection(faceIndex, croppedFaceBitmap)
|
||||
|
||||
// Re-process the results with the manual selection
|
||||
val currentState = _uiState.value
|
||||
if (currentState is ScanningState.Success) {
|
||||
val updatedResult = applyManualSelections(currentState.sanityCheckResult)
|
||||
_uiState.value = ScanningState.Success(updatedResult)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual scanning
|
||||
*/
|
||||
private fun performScan(imageUris: List<Uri>) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = ScanningState.Processing(0, imageUris.size)
|
||||
|
||||
// Perform sanity checks
|
||||
val result = sanityChecker.performSanityChecks(
|
||||
imageUris = imageUris,
|
||||
minImagesRequired = 10,
|
||||
allowMultipleFaces = true, // Allow multiple faces - user can pick
|
||||
duplicateSimilarityThreshold = 0.95
|
||||
)
|
||||
|
||||
// Apply any manual face selections
|
||||
val finalResult = applyManualSelections(result)
|
||||
|
||||
_uiState.value = ScanningState.Success(finalResult)
|
||||
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = ScanningState.Error(
|
||||
e.message ?: "An unknown error occurred"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply manual face selections to the results
|
||||
*/
|
||||
private fun applyManualSelections(
|
||||
result: TrainingSanityChecker.SanityCheckResult
|
||||
): TrainingSanityChecker.SanityCheckResult {
|
||||
|
||||
// If no manual selections, return original
|
||||
if (manualFaceSelections.isEmpty()) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Update face detection results with manual selections
|
||||
val updatedFaceResults = result.faceDetectionResults.map { faceResult ->
|
||||
val manualSelection = manualFaceSelections[faceResult.uri]
|
||||
if (manualSelection != null) {
|
||||
// Replace the cropped face with the manually selected one
|
||||
faceResult.copy(
|
||||
croppedFaceBitmap = manualSelection.croppedFaceBitmap,
|
||||
// Treat as single face since user selected one
|
||||
faceCount = 1
|
||||
)
|
||||
} else {
|
||||
faceResult
|
||||
}
|
||||
}
|
||||
|
||||
// Update valid images list
|
||||
val updatedValidImages = updatedFaceResults
|
||||
.filter { it.hasFace }
|
||||
.filter { it.croppedFaceBitmap != null }
|
||||
.filter { it.errorMessage == null }
|
||||
.filter { it.faceCount >= 1 } // Now accept if user picked a face
|
||||
.map { result ->
|
||||
TrainingSanityChecker.ValidTrainingImage(
|
||||
uri = result.uri,
|
||||
croppedFaceBitmap = result.croppedFaceBitmap!!,
|
||||
faceCount = result.faceCount
|
||||
)
|
||||
}
|
||||
|
||||
// Recalculate validation errors
|
||||
val updatedErrors = result.validationErrors.toMutableList()
|
||||
|
||||
// Remove multiple face errors for images with manual selections
|
||||
updatedErrors.removeAll { error ->
|
||||
error is TrainingSanityChecker.ValidationError.MultipleFacesDetected &&
|
||||
manualFaceSelections.containsKey(error.uri)
|
||||
}
|
||||
|
||||
// Check if we have enough valid images now
|
||||
if (updatedValidImages.size < 10) {
|
||||
if (updatedErrors.none { it is TrainingSanityChecker.ValidationError.InsufficientImages }) {
|
||||
updatedErrors.add(
|
||||
TrainingSanityChecker.ValidationError.InsufficientImages(
|
||||
required = 10,
|
||||
available = updatedValidImages.size
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Remove insufficient images error if we now have enough
|
||||
updatedErrors.removeAll { it is TrainingSanityChecker.ValidationError.InsufficientImages }
|
||||
}
|
||||
|
||||
val isValid = updatedErrors.isEmpty() && updatedValidImages.size >= 10
|
||||
|
||||
return result.copy(
|
||||
isValid = isValid,
|
||||
faceDetectionResults = updatedFaceResults,
|
||||
validationErrors = updatedErrors,
|
||||
validImagesWithFaces = updatedValidImages
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted error messages
|
||||
*/
|
||||
fun getFormattedErrors(result: TrainingSanityChecker.SanityCheckResult): List<String> {
|
||||
return sanityChecker.formatValidationErrors(result.validationErrors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to idle state
|
||||
*/
|
||||
fun reset() {
|
||||
_uiState.value = ScanningState.Idle
|
||||
currentImageUris = emptyList()
|
||||
manualFaceSelections.clear()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
sanityChecker.cleanup()
|
||||
faceDetectionHelper.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// Extension function to copy FaceDetectionResult with modifications
|
||||
private fun FaceDetectionHelper.FaceDetectionResult.copy(
|
||||
uri: Uri = this.uri,
|
||||
hasFace: Boolean = this.hasFace,
|
||||
faceCount: Int = this.faceCount,
|
||||
faceBounds: List<android.graphics.Rect> = this.faceBounds,
|
||||
croppedFaceBitmap: Bitmap? = this.croppedFaceBitmap,
|
||||
errorMessage: String? = this.errorMessage
|
||||
): FaceDetectionHelper.FaceDetectionResult {
|
||||
return FaceDetectionHelper.FaceDetectionResult(
|
||||
uri = uri,
|
||||
hasFace = hasFace,
|
||||
faceCount = faceCount,
|
||||
faceBounds = faceBounds,
|
||||
croppedFaceBitmap = croppedFaceBitmap,
|
||||
errorMessage = errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
// Extension function to copy SanityCheckResult with modifications
|
||||
private fun TrainingSanityChecker.SanityCheckResult.copy(
|
||||
isValid: Boolean = this.isValid,
|
||||
faceDetectionResults: List<FaceDetectionHelper.FaceDetectionResult> = this.faceDetectionResults,
|
||||
duplicateCheckResult: DuplicateImageDetector.DuplicateCheckResult = this.duplicateCheckResult,
|
||||
validationErrors: List<TrainingSanityChecker.ValidationError> = this.validationErrors,
|
||||
warnings: List<String> = this.warnings,
|
||||
validImagesWithFaces: List<TrainingSanityChecker.ValidTrainingImage> = this.validImagesWithFaces
|
||||
): TrainingSanityChecker.SanityCheckResult {
|
||||
return TrainingSanityChecker.SanityCheckResult(
|
||||
isValid = isValid,
|
||||
faceDetectionResults = faceDetectionResults,
|
||||
duplicateCheckResult = duplicateCheckResult,
|
||||
validationErrors = validationErrors,
|
||||
warnings = warnings,
|
||||
validImagesWithFaces = validImagesWithFaces
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrainingScreen(
|
||||
onSelectImages: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Training") }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Button(
|
||||
modifier = Modifier.padding(padding),
|
||||
onClick = onSelectImages
|
||||
) {
|
||||
Text("Select Images")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Coordinates sanity checks for training images
|
||||
*/
|
||||
class TrainingSanityChecker(private val context: Context) {
|
||||
|
||||
private val faceDetectionHelper = FaceDetectionHelper(context)
|
||||
private val duplicateDetector = DuplicateImageDetector(context)
|
||||
|
||||
data class SanityCheckResult(
|
||||
val isValid: Boolean,
|
||||
val faceDetectionResults: List<FaceDetectionHelper.FaceDetectionResult>,
|
||||
val duplicateCheckResult: DuplicateImageDetector.DuplicateCheckResult,
|
||||
val validationErrors: List<ValidationError>,
|
||||
val warnings: List<String>,
|
||||
val validImagesWithFaces: List<ValidTrainingImage>
|
||||
)
|
||||
|
||||
data class ValidTrainingImage(
|
||||
val uri: Uri,
|
||||
val croppedFaceBitmap: Bitmap,
|
||||
val faceCount: Int
|
||||
)
|
||||
|
||||
sealed class ValidationError {
|
||||
data class NoFaceDetected(val uris: List<Uri>) : ValidationError()
|
||||
data class MultipleFacesDetected(val uri: Uri, val faceCount: Int) : ValidationError()
|
||||
data class DuplicateImages(val groups: List<DuplicateImageDetector.DuplicateGroup>) : ValidationError()
|
||||
data class InsufficientImages(val required: Int, val available: Int) : ValidationError()
|
||||
data class ImageLoadError(val uri: Uri, val error: String) : ValidationError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform comprehensive sanity checks on training images
|
||||
*/
|
||||
suspend fun performSanityChecks(
|
||||
imageUris: List<Uri>,
|
||||
minImagesRequired: Int = 10,
|
||||
allowMultipleFaces: Boolean = false,
|
||||
duplicateSimilarityThreshold: Double = 0.95
|
||||
): SanityCheckResult {
|
||||
|
||||
val validationErrors = mutableListOf<ValidationError>()
|
||||
val warnings = mutableListOf<String>()
|
||||
|
||||
// Check minimum image count
|
||||
if (imageUris.size < minImagesRequired) {
|
||||
validationErrors.add(
|
||||
ValidationError.InsufficientImages(
|
||||
required = minImagesRequired,
|
||||
available = imageUris.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Step 1: Detect faces in all images
|
||||
val faceDetectionResults = faceDetectionHelper.detectFacesInImages(imageUris)
|
||||
|
||||
// Check for images without faces
|
||||
val imagesWithoutFaces = faceDetectionResults.filter { !it.hasFace }
|
||||
if (imagesWithoutFaces.isNotEmpty()) {
|
||||
validationErrors.add(
|
||||
ValidationError.NoFaceDetected(
|
||||
uris = imagesWithoutFaces.map { it.uri }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check for images with errors
|
||||
faceDetectionResults.filter { it.errorMessage != null }.forEach { result ->
|
||||
validationErrors.add(
|
||||
ValidationError.ImageLoadError(
|
||||
uri = result.uri,
|
||||
error = result.errorMessage ?: "Unknown error"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check for images with multiple faces
|
||||
if (!allowMultipleFaces) {
|
||||
faceDetectionResults.filter { it.faceCount > 1 }.forEach { result ->
|
||||
validationErrors.add(
|
||||
ValidationError.MultipleFacesDetected(
|
||||
uri = result.uri,
|
||||
faceCount = result.faceCount
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
faceDetectionResults.filter { it.faceCount > 1 }.forEach { result ->
|
||||
warnings.add("Image ${result.uri.lastPathSegment} contains ${result.faceCount} faces. Using the largest detected face.")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check for duplicate images
|
||||
val duplicateCheckResult = duplicateDetector.checkForDuplicates(
|
||||
uris = imageUris,
|
||||
similarityThreshold = duplicateSimilarityThreshold
|
||||
)
|
||||
|
||||
if (duplicateCheckResult.hasDuplicates) {
|
||||
validationErrors.add(
|
||||
ValidationError.DuplicateImages(
|
||||
groups = duplicateCheckResult.duplicateGroups
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: Create list of valid training images
|
||||
val validImagesWithFaces = faceDetectionResults
|
||||
.filter { it.hasFace && it.croppedFaceBitmap != null }
|
||||
.filter { allowMultipleFaces || it.faceCount == 1 }
|
||||
.map { result ->
|
||||
ValidTrainingImage(
|
||||
uri = result.uri,
|
||||
croppedFaceBitmap = result.croppedFaceBitmap!!,
|
||||
faceCount = result.faceCount
|
||||
)
|
||||
}
|
||||
|
||||
// Check if we have enough valid images after all checks
|
||||
if (validImagesWithFaces.size < minImagesRequired) {
|
||||
val existingError = validationErrors.find { it is ValidationError.InsufficientImages }
|
||||
if (existingError == null) {
|
||||
validationErrors.add(
|
||||
ValidationError.InsufficientImages(
|
||||
required = minImagesRequired,
|
||||
available = validImagesWithFaces.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val isValid = validationErrors.isEmpty() && validImagesWithFaces.size >= minImagesRequired
|
||||
|
||||
return SanityCheckResult(
|
||||
isValid = isValid,
|
||||
faceDetectionResults = faceDetectionResults,
|
||||
duplicateCheckResult = duplicateCheckResult,
|
||||
validationErrors = validationErrors,
|
||||
warnings = warnings,
|
||||
validImagesWithFaces = validImagesWithFaces
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation errors into human-readable messages
|
||||
*/
|
||||
fun formatValidationErrors(errors: List<ValidationError>): List<String> {
|
||||
return errors.map { error ->
|
||||
when (error) {
|
||||
is ValidationError.NoFaceDetected -> {
|
||||
val count = error.uris.size
|
||||
val images = error.uris.joinToString(", ") { it.lastPathSegment ?: "Unknown" }
|
||||
"No face detected in $count image(s): $images"
|
||||
}
|
||||
is ValidationError.MultipleFacesDetected -> {
|
||||
"Multiple faces (${error.faceCount}) detected in: ${error.uri.lastPathSegment}"
|
||||
}
|
||||
is ValidationError.DuplicateImages -> {
|
||||
val count = error.groups.size
|
||||
val details = error.groups.joinToString("\n") { group ->
|
||||
" - ${group.images.size} duplicates: ${group.images.joinToString(", ") { it.lastPathSegment ?: "Unknown" }}"
|
||||
}
|
||||
"Found $count duplicate group(s):\n$details"
|
||||
}
|
||||
is ValidationError.InsufficientImages -> {
|
||||
"Insufficient images: need ${error.required}, but only ${error.available} valid images available"
|
||||
}
|
||||
is ValidationError.ImageLoadError -> {
|
||||
"Failed to load image ${error.uri.lastPathSegment}: ${error.error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
fun cleanup() {
|
||||
faceDetectionHelper.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.placeholder.sherpai2.ui.trainingprep
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel for managing training image sanity checks
|
||||
*/
|
||||
class TrainingSanityViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val sanityChecker = TrainingSanityChecker(application)
|
||||
|
||||
private val _uiState = MutableStateFlow<TrainingSanityUiState>(TrainingSanityUiState.Idle)
|
||||
val uiState: StateFlow<TrainingSanityUiState> = _uiState.asStateFlow()
|
||||
|
||||
sealed class TrainingSanityUiState {
|
||||
object Idle : TrainingSanityUiState()
|
||||
object Checking : TrainingSanityUiState()
|
||||
data class Success(
|
||||
val result: TrainingSanityChecker.SanityCheckResult
|
||||
) : TrainingSanityUiState()
|
||||
data class Error(val message: String) : TrainingSanityUiState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform sanity checks on selected images
|
||||
*/
|
||||
fun checkImages(
|
||||
imageUris: List<Uri>,
|
||||
minImagesRequired: Int = 10,
|
||||
allowMultipleFaces: Boolean = false,
|
||||
duplicateSimilarityThreshold: Double = 0.95
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = TrainingSanityUiState.Checking
|
||||
|
||||
val result = sanityChecker.performSanityChecks(
|
||||
imageUris = imageUris,
|
||||
minImagesRequired = minImagesRequired,
|
||||
allowMultipleFaces = allowMultipleFaces,
|
||||
duplicateSimilarityThreshold = duplicateSimilarityThreshold
|
||||
)
|
||||
|
||||
_uiState.value = TrainingSanityUiState.Success(result)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = TrainingSanityUiState.Error(
|
||||
e.message ?: "An unknown error occurred during sanity checks"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the UI state
|
||||
*/
|
||||
fun resetState() {
|
||||
_uiState.value = TrainingSanityUiState.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted error messages from validation result
|
||||
*/
|
||||
fun getFormattedErrors(result: TrainingSanityChecker.SanityCheckResult): List<String> {
|
||||
return sanityChecker.formatValidationErrors(result.validationErrors)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
sanityChecker.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ room = "2.8.4"
|
||||
# Images
|
||||
coil = "2.7.0"
|
||||
|
||||
#Face Detect
|
||||
mlkit-face-detection = "16.1.6"
|
||||
coroutines-play-services = "1.8.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
@@ -48,6 +52,11 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref =
|
||||
# Misc
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
#Face Detect
|
||||
mlkit-face-detection = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkit-face-detection"}
|
||||
kotlinx-coroutines-play-services = {group = "org.jetbrains.kotlinx",name = "kotlinx-coroutines-play-services",version.ref = "coroutines-play-services"}
|
||||
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
|
||||
Reference in New Issue
Block a user