1 Commits

Author SHA1 Message Date
genki
2b5f761d25 Oh yes - Thats how we do
No default params for KSP complainer fuck

UI sweeps
2026-01-10 00:08:04 -05:00
18 changed files with 3680 additions and 366 deletions

View File

@@ -10,6 +10,7 @@ import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -19,7 +20,6 @@ import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.ui.presentation.MainScreen import com.placeholder.sherpai2.ui.presentation.MainScreen
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -31,11 +31,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Determine storage permission based on Android version
val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES Manifest.permission.READ_MEDIA_IMAGES
} else { } else {
@Suppress("DEPRECATION")
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
} }
@@ -43,44 +41,53 @@ class MainActivity : ComponentActivity() {
SherpAI2Theme { SherpAI2Theme {
var hasPermission by remember { var hasPermission by remember {
mutableStateOf( mutableStateOf(
ContextCompat.checkSelfPermission(this, storagePermission) == ContextCompat.checkSelfPermission(this@MainActivity, storagePermission) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
) )
} }
// Track ingestion completion var isIngesting by remember { mutableStateOf(false) }
var imagesIngested by remember { mutableStateOf(false) } var imagesIngested by remember { mutableStateOf(false) }
// Launcher for permission request
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
hasPermission = granted hasPermission = granted
} }
// Trigger ingestion once permission is granted // Logic: Handle the flow of Permission -> Ingestion
LaunchedEffect(hasPermission) { LaunchedEffect(hasPermission) {
if (hasPermission) { if (hasPermission) {
// Suspend until ingestion completes if (!imagesIngested && !isIngesting) {
imageRepository.ingestImages() isIngesting = true
imagesIngested = true imageRepository.ingestImages()
imagesIngested = true
isIngesting = false
}
} else { } else {
permissionLauncher.launch(storagePermission) permissionLauncher.launch(storagePermission)
} }
} }
// Gate UI until permission granted AND ingestion completed // UI State Mapping
if (hasPermission && imagesIngested) { Box(
MainScreen() modifier = Modifier.fillMaxSize(),
} else { contentAlignment = Alignment.Center
Box( ) {
modifier = Modifier.fillMaxSize(), when {
contentAlignment = Alignment.Center hasPermission && imagesIngested -> {
) { MainScreen()
Text("Please grant storage permission to continue.") }
hasPermission && isIngesting -> {
// Show a loader so you know it's working!
CircularProgressIndicator()
}
else -> {
Text("Please grant storage permission to continue.")
}
} }
} }
} }
} }
} }
} }

View File

@@ -0,0 +1,380 @@
package com.placeholder.sherpai2.data.service
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.util.DiagnosticLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Calendar
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* AutoTaggingService - Intelligent auto-tagging system
*
* Capabilities:
* - Face-based tags (group_photo, selfie, couple)
* - Scene tags (portrait, landscape, square orientation)
* - Time tags (morning, afternoon, evening, night)
* - Quality tags (high_res, low_res)
* - Relationship tags (family, friend, colleague from PersonEntity)
* - Birthday tags (from PersonEntity DOB)
* - Indoor/Outdoor estimation (basic heuristic)
*/
@Singleton
class AutoTaggingService @Inject constructor(
@ApplicationContext private val context: Context,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val photoFaceTagDao: PhotoFaceTagDao,
private val personDao: PersonDao
) {
// ======================
// MAIN AUTO-TAGGING
// ======================
/**
* Auto-tag an image with all applicable system tags
*
* @return Number of tags applied
*/
suspend fun autoTagImage(
imageEntity: ImageEntity,
bitmap: Bitmap,
detectedFaces: List<DetectedFace>
): Int = withContext(Dispatchers.Default) {
val tagsToApply = mutableListOf<String>()
// Face-count based tags
when (detectedFaces.size) {
0 -> { /* No face tags */ }
1 -> {
if (isSelfie(detectedFaces[0], bitmap)) {
tagsToApply.add("selfie")
} else {
tagsToApply.add("single_person")
}
}
2 -> tagsToApply.add("couple")
in 3..5 -> tagsToApply.add("group_photo")
in 6..10 -> {
tagsToApply.add("group_photo")
tagsToApply.add("large_group")
}
else -> {
tagsToApply.add("group_photo")
tagsToApply.add("large_group")
tagsToApply.add("crowd")
}
}
// Orientation tags
val aspectRatio = bitmap.width.toFloat() / bitmap.height.toFloat()
when {
aspectRatio > 1.3f -> tagsToApply.add("landscape")
aspectRatio < 0.77f -> tagsToApply.add("portrait")
else -> tagsToApply.add("square")
}
// Resolution tags
val megapixels = (bitmap.width * bitmap.height) / 1_000_000f
when {
megapixels > 2.0f -> tagsToApply.add("high_res")
megapixels < 0.5f -> tagsToApply.add("low_res")
}
// Time-based tags
val hourOfDay = getHourFromTimestamp(imageEntity.capturedAt)
tagsToApply.add(when (hourOfDay) {
in 5..10 -> "morning"
in 11..16 -> "afternoon"
in 17..20 -> "evening"
else -> "night"
})
// Indoor/Outdoor estimation (only if image is large enough)
if (bitmap.width >= 200 && bitmap.height >= 200) {
val isIndoor = estimateIndoorOutdoor(bitmap)
tagsToApply.add(if (isIndoor) "indoor" else "outdoor")
}
// Apply all tags
var tagsApplied = 0
tagsToApply.forEach { tagName ->
if (applySystemTag(imageEntity.imageId, tagName)) {
tagsApplied++
}
}
DiagnosticLogger.d("AutoTag: Applied $tagsApplied tags to image ${imageEntity.imageId}")
tagsApplied
}
// ======================
// RELATIONSHIP TAGS
// ======================
/**
* Tag all images with a person using their relationship tag
*
* @param personId Person to tag images for
* @return Number of tags applied
*/
suspend fun autoTagRelationshipForPerson(personId: String): Int = withContext(Dispatchers.IO) {
val person = personDao.getPersonById(personId) ?: return@withContext 0
val relationship = person.relationship?.lowercase() ?: return@withContext 0
// Get face model for this person
val faceModels = photoFaceTagDao.getAllTagsForFaceModel(personId)
if (faceModels.isEmpty()) return@withContext 0
val imageIds = faceModels.map { it.imageId }.distinct()
var tagsApplied = 0
imageIds.forEach { imageId ->
if (applySystemTag(imageId, relationship)) {
tagsApplied++
}
}
DiagnosticLogger.i("AutoTag: Applied '$relationship' tag to $tagsApplied images for ${person.name}")
tagsApplied
}
/**
* Tag relationships for ALL persons in database
*/
suspend fun autoTagAllRelationships(): Int = withContext(Dispatchers.IO) {
val persons = personDao.getAllPersons()
var totalTags = 0
persons.forEach { person ->
totalTags += autoTagRelationshipForPerson(person.id)
}
DiagnosticLogger.i("AutoTag: Applied $totalTags relationship tags across ${persons.size} persons")
totalTags
}
// ======================
// BIRTHDAY TAGS
// ======================
/**
* Tag images near a person's birthday
*
* @param personId Person whose birthday to check
* @param daysRange Days before/after birthday to consider (default: 3)
* @return Number of tags applied
*/
suspend fun autoTagBirthdaysForPerson(
personId: String,
daysRange: Int = 3
): Int = withContext(Dispatchers.IO) {
val person = personDao.getPersonById(personId) ?: return@withContext 0
val dateOfBirth = person.dateOfBirth ?: return@withContext 0
// Get all face tags for this person
val faceTags = photoFaceTagDao.getAllTagsForFaceModel(personId)
if (faceTags.isEmpty()) return@withContext 0
var tagsApplied = 0
faceTags.forEach { faceTag ->
// Get the image to check its timestamp
val imageId = faceTag.imageId
// Check if image was captured near birthday
if (isNearBirthday(faceTag.detectedAt, dateOfBirth, daysRange)) {
if (applySystemTag(imageId, "birthday")) {
tagsApplied++
}
}
}
DiagnosticLogger.i("AutoTag: Applied 'birthday' tag to $tagsApplied images for ${person.name}")
tagsApplied
}
/**
* Tag birthdays for ALL persons with DOB
*/
suspend fun autoTagAllBirthdays(daysRange: Int = 3): Int = withContext(Dispatchers.IO) {
val persons = personDao.getAllPersons()
var totalTags = 0
persons.forEach { person ->
if (person.dateOfBirth != null) {
totalTags += autoTagBirthdaysForPerson(person.id, daysRange)
}
}
DiagnosticLogger.i("AutoTag: Applied $totalTags birthday tags")
totalTags
}
// ======================
// HELPER METHODS
// ======================
/**
* Check if an image is a selfie based on face size
*/
private fun isSelfie(face: DetectedFace, bitmap: Bitmap): Boolean {
val boundingBox = face.boundingBox
val faceArea = boundingBox.width() * boundingBox.height()
val imageArea = bitmap.width * bitmap.height
val faceRatio = faceArea.toFloat() / imageArea.toFloat()
// Selfie = face takes up significant portion (>15% of image)
return faceRatio > 0.15f
}
/**
* Get hour of day from timestamp (0-23)
*/
private fun getHourFromTimestamp(timestamp: Long): Int {
return Calendar.getInstance().apply {
timeInMillis = timestamp
}.get(Calendar.HOUR_OF_DAY)
}
/**
* Check if a timestamp is near a birthday
*/
private fun isNearBirthday(
capturedTimestamp: Long,
dobTimestamp: Long,
daysRange: Int
): Boolean {
val capturedCal = Calendar.getInstance().apply { timeInMillis = capturedTimestamp }
val dobCal = Calendar.getInstance().apply { timeInMillis = dobTimestamp }
val capturedMonth = capturedCal.get(Calendar.MONTH)
val capturedDay = capturedCal.get(Calendar.DAY_OF_MONTH)
val dobMonth = dobCal.get(Calendar.MONTH)
val dobDay = dobCal.get(Calendar.DAY_OF_MONTH)
if (capturedMonth == dobMonth) {
return abs(capturedDay - dobDay) <= daysRange
}
// Handle edge case: birthday near end/start of month
// e.g., DOB = Jan 2, captured = Dec 31 (within 3 days)
if (abs(capturedMonth - dobMonth) == 1 || abs(capturedMonth - dobMonth) == 11) {
val daysInCapturedMonth = capturedCal.getActualMaximum(Calendar.DAY_OF_MONTH)
val daysInDobMonth = dobCal.getActualMaximum(Calendar.DAY_OF_MONTH)
if (capturedMonth < dobMonth || (capturedMonth == 11 && dobMonth == 0)) {
// Captured before DOB month
val dayDiff = (daysInCapturedMonth - capturedDay) + dobDay
return dayDiff <= daysRange
} else {
// Captured after DOB month
val dayDiff = (daysInDobMonth - dobDay) + capturedDay
return dayDiff <= daysRange
}
}
return false
}
/**
* Basic indoor/outdoor estimation using brightness and saturation
*
* Heuristic:
* - Outdoor: Higher brightness (>120), Higher saturation (>0.25)
* - Indoor: Lower brightness, Lower saturation
*/
private fun estimateIndoorOutdoor(bitmap: Bitmap): Boolean {
// Sample pixels for analysis (don't process entire image)
val sampleSize = 100
val sampledPixels = mutableListOf<Int>()
val stepX = bitmap.width / sampleSize.coerceAtMost(bitmap.width)
val stepY = bitmap.height / sampleSize.coerceAtMost(bitmap.height)
for (x in 0 until sampleSize.coerceAtMost(bitmap.width)) {
for (y in 0 until sampleSize.coerceAtMost(bitmap.height)) {
val px = (x * stepX).coerceIn(0, bitmap.width - 1)
val py = (y * stepY).coerceIn(0, bitmap.height - 1)
sampledPixels.add(bitmap.getPixel(px, py))
}
}
if (sampledPixels.isEmpty()) return true // Default to indoor if sampling fails
// Calculate average brightness
val avgBrightness = sampledPixels.map { pixel ->
val r = Color.red(pixel)
val g = Color.green(pixel)
val b = Color.blue(pixel)
(r + g + b) / 3.0f
}.average()
// Calculate color saturation
val avgSaturation = sampledPixels.map { pixel ->
val hsv = FloatArray(3)
Color.colorToHSV(pixel, hsv)
hsv[1] // Saturation
}.average()
// Heuristic: Indoor if low brightness OR low saturation
return avgBrightness < 120 || avgSaturation < 0.25
}
/**
* Apply a system tag to an image (helper to avoid duplicates)
*
* @return true if tag was applied, false if already exists
*/
private suspend fun applySystemTag(imageId: String, tagName: String): Boolean {
return withContext(Dispatchers.IO) {
try {
// Get or create tag
val tag = getOrCreateSystemTag(tagName)
// Create image-tag link
val imageTag = ImageTagEntity(
imageId = imageId,
tagId = tag.tagId,
source = "AUTO",
confidence = 1.0f,
visibility = "PUBLIC",
createdAt = System.currentTimeMillis()
)
imageTagDao.upsert(imageTag)
true
} catch (e: Exception) {
DiagnosticLogger.e("Failed to apply tag '$tagName' to image $imageId", e)
false
}
}
}
/**
* Get existing system tag or create new one
*/
private suspend fun getOrCreateSystemTag(tagName: String): TagEntity {
return withContext(Dispatchers.IO) {
tagDao.getByValue(tagName) ?: run {
val newTag = TagEntity.createSystemTag(tagName)
tagDao.insert(newTag)
newTag
}
}
}
}

View File

@@ -0,0 +1,127 @@
package com.placeholder.sherpai2.ml
/**
* ThresholdStrategy - Smart threshold selection for face recognition
*
* Considers:
* - Training image count
* - Image quality
* - Detection context (group photo, selfie, etc.)
*/
object ThresholdStrategy {
/**
* Get optimal threshold for face recognition
*
* @param trainingCount Number of images used to train the model
* @param imageQuality Quality assessment of the image being scanned
* @param detectionContext Context of the detection (group, selfie, etc.)
* @return Similarity threshold (0.0 - 1.0)
*/
fun getOptimalThreshold(
trainingCount: Int,
imageQuality: ImageQuality = ImageQuality.UNKNOWN,
detectionContext: DetectionContext = DetectionContext.GENERAL
): Float {
// Base threshold from training count
val baseThreshold = when {
trainingCount >= 40 -> 0.68f // High confidence - strict
trainingCount >= 30 -> 0.62f // Good confidence - moderate-strict
trainingCount >= 20 -> 0.56f // Moderate confidence
trainingCount >= 15 -> 0.50f // Acceptable confidence - lenient
else -> 0.48f // Sparse training - very lenient
}
// Adjust based on image quality
val qualityAdjustment = when (imageQuality) {
ImageQuality.HIGH -> -0.02f // Can be stricter with good quality
ImageQuality.MEDIUM -> 0f // No change
ImageQuality.LOW -> +0.03f // Be more lenient with poor quality
ImageQuality.UNKNOWN -> 0f // No change
}
// Adjust based on detection context
val contextAdjustment = when (detectionContext) {
DetectionContext.GROUP_PHOTO -> +0.02f // More lenient in groups (faces smaller)
DetectionContext.SELFIE -> -0.03f // Stricter for close-ups (more detail)
DetectionContext.PROFILE -> +0.02f // More lenient for side profiles
DetectionContext.DISTANT -> +0.03f // More lenient for far away faces
DetectionContext.GENERAL -> 0f // No change
}
// Combine adjustments and clamp to safe range
return (baseThreshold + qualityAdjustment + contextAdjustment).coerceIn(0.40f, 0.75f)
}
/**
* Get threshold for liberal matching (e.g., during testing)
*/
fun getLiberalThreshold(trainingCount: Int): Float {
return when {
trainingCount >= 30 -> 0.52f
trainingCount >= 20 -> 0.48f
else -> 0.45f
}.coerceIn(0.40f, 0.65f)
}
/**
* Get threshold for conservative matching (minimize false positives)
*/
fun getConservativeThreshold(trainingCount: Int): Float {
return when {
trainingCount >= 40 -> 0.72f
trainingCount >= 30 -> 0.68f
trainingCount >= 20 -> 0.62f
else -> 0.58f
}.coerceIn(0.55f, 0.75f)
}
/**
* Estimate image quality from bitmap properties
*/
fun estimateImageQuality(width: Int, height: Int, fileSize: Long = 0): ImageQuality {
val megapixels = (width * height) / 1_000_000f
return when {
megapixels > 4.0f -> ImageQuality.HIGH
megapixels > 1.0f -> ImageQuality.MEDIUM
else -> ImageQuality.LOW
}
}
/**
* Estimate detection context from face count and face size
*/
fun estimateDetectionContext(
faceCount: Int,
faceAreaRatio: Float = 0f
): DetectionContext {
return when {
faceCount == 1 && faceAreaRatio > 0.15f -> DetectionContext.SELFIE
faceCount == 1 && faceAreaRatio < 0.05f -> DetectionContext.DISTANT
faceCount >= 3 -> DetectionContext.GROUP_PHOTO
else -> DetectionContext.GENERAL
}
}
}
/**
* Image quality assessment
*/
enum class ImageQuality {
HIGH, // > 4MP, good lighting
MEDIUM, // 1-4MP
LOW, // < 1MP, poor quality
UNKNOWN // Cannot determine
}
/**
* Detection context
*/
enum class DetectionContext {
GROUP_PHOTO, // Multiple faces (3+)
SELFIE, // Single face, close-up
PROFILE, // Side view
DISTANT, // Face is small in frame
GENERAL // Default
}

View File

@@ -0,0 +1,336 @@
package com.placeholder.sherpai2.ui.album
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.ui.search.DateRange
import com.placeholder.sherpai2.ui.search.DisplayMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
/**
* AlbumViewModel - Display photos from a specific album (tag, person, or time range)
*
* Features:
* - Search within album
* - Date filtering
* - Simple/Verbose toggle
* - Album stats
*/
@HiltViewModel
class AlbumViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val imageDao: ImageDao,
private val personDao: PersonDao,
private val faceRecognitionRepository: FaceRecognitionRepository
) : ViewModel() {
// Album parameters from navigation
private val albumType: String = savedStateHandle["albumType"] ?: "tag"
private val albumId: String = savedStateHandle["albumId"] ?: ""
// UI state
private val _uiState = MutableStateFlow<AlbumUiState>(AlbumUiState.Loading)
val uiState: StateFlow<AlbumUiState> = _uiState.asStateFlow()
// Search query within album
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Date range filter
private val _dateRange = MutableStateFlow(DateRange.ALL_TIME)
val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow()
// Display mode
private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE)
val displayMode: StateFlow<DisplayMode> = _displayMode.asStateFlow()
init {
loadAlbumData()
}
/**
* Load album data based on type
*/
private fun loadAlbumData() {
viewModelScope.launch {
try {
_uiState.value = AlbumUiState.Loading
when (albumType) {
"tag" -> loadTagAlbum()
"person" -> loadPersonAlbum()
"time" -> loadTimeAlbum()
else -> _uiState.value = AlbumUiState.Error("Unknown album type")
}
} catch (e: Exception) {
_uiState.value = AlbumUiState.Error(e.message ?: "Failed to load album")
}
}
}
private suspend fun loadTagAlbum() {
val tag = tagDao.getByValue(albumId)
if (tag == null) {
_uiState.value = AlbumUiState.Error("Tag not found")
return
}
combine(
_searchQuery,
_dateRange
) { query, dateRange ->
Pair(query, dateRange)
}.collectLatest { (query, dateRange) ->
val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f)
val images = imageDao.getImagesByIds(imageIds)
val filteredImages = images
.filter { isInDateRange(it.capturedAt, dateRange) }
.filter {
query.isBlank() || containsQuery(it, query)
}
val imagesWithFaces = filteredImages.map { image ->
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId)
AlbumPhoto(
image = image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
val uniquePersons = imagesWithFaces
.flatMap { it.persons }
.distinctBy { it.id }
_uiState.value = AlbumUiState.Success(
albumName = tag.value.replace("_", " ").capitalize(),
albumType = "Tag",
photos = imagesWithFaces,
personCount = uniquePersons.size,
totalFaces = imagesWithFaces.sumOf { it.faceTags.size }
)
}
}
private suspend fun loadPersonAlbum() {
val person = personDao.getPersonById(albumId)
if (person == null) {
_uiState.value = AlbumUiState.Error("Person not found")
return
}
combine(
_searchQuery,
_dateRange
) { query, dateRange ->
Pair(query, dateRange)
}.collectLatest { (query, dateRange) ->
val images = faceRecognitionRepository.getImagesForPerson(albumId)
val filteredImages = images
.filter { isInDateRange(it.capturedAt, dateRange) }
.filter {
query.isBlank() || containsQuery(it, query)
}
val imagesWithFaces = filteredImages.map { image ->
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId)
AlbumPhoto(
image = image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
_uiState.value = AlbumUiState.Success(
albumName = person.name,
albumType = "Person",
photos = imagesWithFaces,
personCount = 1,
totalFaces = imagesWithFaces.sumOf { it.faceTags.size }
)
}
}
private suspend fun loadTimeAlbum() {
// Time-based albums (Today, This Week, etc)
val (startTime, endTime, albumName) = when (albumId) {
"today" -> Triple(getStartOfDay(), System.currentTimeMillis(), "Today")
"week" -> Triple(getStartOfWeek(), System.currentTimeMillis(), "This Week")
"month" -> Triple(getStartOfMonth(), System.currentTimeMillis(), "This Month")
"year" -> Triple(getStartOfYear(), System.currentTimeMillis(), "This Year")
else -> {
_uiState.value = AlbumUiState.Error("Unknown time range")
return
}
}
combine(
_searchQuery,
_dateRange
) { query, _ ->
query
}.collectLatest { query ->
val images = imageDao.getImagesInRange(startTime, endTime)
val filteredImages = images.filter {
query.isBlank() || containsQuery(it, query)
}
val imagesWithFaces = filteredImages.map { image ->
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId)
AlbumPhoto(
image = image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
val uniquePersons = imagesWithFaces
.flatMap { it.persons }
.distinctBy { it.id }
_uiState.value = AlbumUiState.Success(
albumName = albumName,
albumType = "Time",
photos = imagesWithFaces,
personCount = uniquePersons.size,
totalFaces = imagesWithFaces.sumOf { it.faceTags.size }
)
}
}
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun setDateRange(range: DateRange) {
_dateRange.value = range
}
fun toggleDisplayMode() {
_displayMode.value = when (_displayMode.value) {
DisplayMode.SIMPLE -> DisplayMode.VERBOSE
DisplayMode.VERBOSE -> DisplayMode.SIMPLE
}
}
private fun isInDateRange(timestamp: Long, range: DateRange): Boolean {
return when (range) {
DateRange.ALL_TIME -> true
DateRange.TODAY -> isToday(timestamp)
DateRange.THIS_WEEK -> isThisWeek(timestamp)
DateRange.THIS_MONTH -> isThisMonth(timestamp)
DateRange.THIS_YEAR -> isThisYear(timestamp)
}
}
private fun containsQuery(image: ImageEntity, query: String): Boolean {
// Could expand to search by person names, tags, etc.
return true
}
private fun isToday(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == date.get(Calendar.DAY_OF_YEAR)
}
private fun isThisWeek(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.WEEK_OF_YEAR) == date.get(Calendar.WEEK_OF_YEAR)
}
private fun isThisMonth(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.MONTH) == date.get(Calendar.MONTH)
}
private fun isThisYear(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR)
}
private fun getStartOfDay(): Long {
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfWeek(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfMonth(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfYear(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_YEAR, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}
}
sealed class AlbumUiState {
object Loading : AlbumUiState()
data class Success(
val albumName: String,
val albumType: String,
val photos: List<AlbumPhoto>,
val personCount: Int,
val totalFaces: Int
) : AlbumUiState()
data class Error(val message: String) : AlbumUiState()
}
data class AlbumPhoto(
val image: ImageEntity,
val faceTags: List<PhotoFaceTagEntity>,
val persons: List<PersonEntity>
)

View File

@@ -0,0 +1,358 @@
package com.placeholder.sherpai2.ui.album
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.search.DateRange
import com.placeholder.sherpai2.ui.search.DisplayMode
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
/**
* AlbumViewScreen - Beautiful album detail view
*
* Features:
* - Album stats
* - Search within album
* - Date filtering
* - Simple/Verbose toggle
* - Clean person display
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumViewScreen(
onBack: () -> Unit,
onImageClick: (String) -> Unit,
viewModel: AlbumViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val dateRange by viewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by viewModel.displayMode.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
when (val state = uiState) {
is AlbumUiState.Success -> {
Text(
text = state.albumName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "${state.photos.size} photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
else -> {
Text("Album")
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { viewModel.toggleDisplayMode() }) {
Icon(
imageVector = if (displayMode == DisplayMode.SIMPLE) {
Icons.Default.ViewList
} else {
Icons.Default.ViewModule
},
contentDescription = "Toggle view"
)
}
}
)
}
) { paddingValues ->
when (val state = uiState) {
is AlbumUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AlbumUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(state.message)
Button(onClick = onBack) {
Text("Go Back")
}
}
}
}
is AlbumUiState.Success -> {
AlbumContent(
state = state,
searchQuery = searchQuery,
dateRange = dateRange,
displayMode = displayMode,
onSearchChange = { viewModel.setSearchQuery(it) },
onDateRangeChange = { viewModel.setDateRange(it) },
onImageClick = onImageClick,
modifier = Modifier.padding(paddingValues)
)
}
}
}
}
@Composable
private fun AlbumContent(
state: AlbumUiState.Success,
searchQuery: String,
dateRange: DateRange,
displayMode: DisplayMode,
onSearchChange: (String) -> Unit,
onDateRangeChange: (DateRange) -> Unit,
onImageClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize()
) {
// Stats card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceAround
) {
StatItem(Icons.Default.Photo, "Photos", state.photos.size.toString())
if (state.totalFaces > 0) {
StatItem(Icons.Default.Face, "Faces", state.totalFaces.toString())
}
if (state.personCount > 0) {
StatItem(Icons.Default.People, "People", state.personCount.toString())
}
}
}
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChange,
placeholder = { Text("Search in album...") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchChange("") }) {
Icon(Icons.Default.Clear, "Clear")
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
singleLine = true,
shape = RoundedCornerShape(16.dp)
)
Spacer(Modifier.height(8.dp))
// Date filters
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DateRange.entries) { range ->
val isActive = dateRange == range
FilterChip(
selected = isActive,
onClick = { onDateRangeChange(range) },
label = { Text(range.displayName) }
)
}
}
Spacer(Modifier.height(8.dp))
// Photo grid
if (state.photos.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No photos in this album",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = state.photos,
key = { it.image.imageId }
) { photo ->
PhotoCard(
photo = photo,
displayMode = displayMode,
onImageClick = onImageClick
)
}
}
}
}
}
@Composable
private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun PhotoCard(
photo: AlbumPhoto,
displayMode: DisplayMode,
onImageClick: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column {
ImageGridItem(
image = photo.image,
onClick = { onImageClick(photo.image.imageUri) }
)
if (photo.persons.isNotEmpty()) {
when (displayMode) {
DisplayMode.SIMPLE -> {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = photo.persons.take(3).joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
DisplayMode.VERBOSE -> {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
photo.persons.take(3).forEachIndexed { index, person ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Face,
null,
Modifier.size(14.dp),
MaterialTheme.colorScheme.primary
)
Text(
text = person.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (index < photo.faceTags.size) {
val confidence = (photo.faceTags[index].confidence * 100).toInt()
Text(
text = "$confidence%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,459 @@
package com.placeholder.sherpai2.ui.explore
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
/**
* ExploreScreen - REDESIGNED
*
* Features:
* - Rectangular album cards (more compact)
* - Stories section (recent highlights)
* - Clickable navigation to AlbumViewScreen
* - Beautiful gradients and icons
*/
@Composable
fun ExploreScreen(
onAlbumClick: (albumType: String, albumId: String) -> Unit,
viewModel: ExploreViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
) {
// Header with gradient
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.surface
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Explore",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Your photo collection organized",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
when (val state = uiState) {
is ExploreViewModel.ExploreUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ExploreViewModel.ExploreUiState.Success -> {
ExploreContent(
smartAlbums = state.smartAlbums,
onAlbumClick = onAlbumClick
)
}
is ExploreViewModel.ExploreUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = state.message,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
@Composable
private fun ExploreContent(
smartAlbums: List<SmartAlbum>,
onAlbumClick: (albumType: String, albumId: String) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Stories Section (Recent Highlights)
item {
StoriesSection(
albums = smartAlbums.filter { it.imageCount > 0 }.take(10),
onAlbumClick = onAlbumClick
)
}
// Time-based Albums
val timeAlbums = smartAlbums.filterIsInstance<SmartAlbum.TimeRange>()
if (timeAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "📅 Time Capsules",
albums = timeAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Face-based Albums
val faceAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("group_photo", "selfie", "couple") }
if (faceAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "👥 People & Groups",
albums = faceAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Relationship Albums
val relationshipAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("family", "friend", "colleague") }
if (relationshipAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "❤️ Relationships",
albums = relationshipAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Time of Day Albums
val timeOfDayAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("morning", "afternoon", "evening", "night") }
if (timeOfDayAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "🌅 Times of Day",
albums = timeOfDayAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Scene Albums
val sceneAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("indoor", "outdoor") }
if (sceneAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "🏞️ Scenes",
albums = sceneAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Special Occasions
val specialAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("birthday", "high_res") }
if (specialAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "⭐ Special",
albums = specialAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Person Albums
val personAlbums = smartAlbums.filterIsInstance<SmartAlbum.Person>()
if (personAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "👤 People",
albums = personAlbums,
onAlbumClick = onAlbumClick
)
}
}
}
}
/**
* Stories section - Instagram-style circular highlights
*/
@Composable
private fun StoriesSection(
albums: List<SmartAlbum>,
onAlbumClick: (albumType: String, albumId: String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "📖 Stories",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(albums) { album ->
StoryCircle(
album = album,
onClick = {
val (type, id) = getAlbumNavigation(album)
onAlbumClick(type, id)
}
)
}
}
}
}
/**
* Story circle - circular album preview
*/
@Composable
private fun StoryCircle(
album: SmartAlbum,
onClick: () -> Unit
) {
val (icon, gradient) = getAlbumIconAndGradient(album)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(gradient),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
Text(
text = album.displayName,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
modifier = Modifier.width(80.dp),
fontWeight = FontWeight.Medium
)
Text(
text = "${album.imageCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Album section with horizontal scrolling rectangular cards
*/
@Composable
private fun AlbumSection(
title: String,
albums: List<SmartAlbum>,
onAlbumClick: (albumType: String, albumId: String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(albums) { album ->
AlbumCard(
album = album,
onClick = {
val (type, id) = getAlbumNavigation(album)
onAlbumClick(type, id)
}
)
}
}
}
}
/**
* Rectangular album card - more compact than square
*/
@Composable
private fun AlbumCard(
album: SmartAlbum,
onClick: () -> Unit
) {
val (icon, gradient) = getAlbumIconAndGradient(album)
Card(
modifier = Modifier
.width(180.dp)
.height(120.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(gradient)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
// Icon
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
// Album info
Column {
Text(
text = album.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1
)
Text(
text = "${album.imageCount} ${if (album.imageCount == 1) "photo" else "photos"}",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
}
/**
* Get navigation parameters for album
*/
private fun getAlbumNavigation(album: SmartAlbum): Pair<String, String> {
return when (album) {
is SmartAlbum.TimeRange.Today -> "time" to "today"
is SmartAlbum.TimeRange.ThisWeek -> "time" to "week"
is SmartAlbum.TimeRange.ThisMonth -> "time" to "month"
is SmartAlbum.TimeRange.LastYear -> "time" to "year"
is SmartAlbum.Tagged -> "tag" to album.tagValue
is SmartAlbum.Person -> "person" to album.personId
}
}
/**
* Get icon and gradient for album type
*/
private fun getAlbumIconAndGradient(album: SmartAlbum): Pair<ImageVector, Brush> {
return when (album) {
is SmartAlbum.TimeRange.Today -> Icons.Default.Today to gradientBlue()
is SmartAlbum.TimeRange.ThisWeek -> Icons.Default.DateRange to gradientTeal()
is SmartAlbum.TimeRange.ThisMonth -> Icons.Default.CalendarMonth to gradientGreen()
is SmartAlbum.TimeRange.LastYear -> Icons.Default.HistoryEdu to gradientPurple()
is SmartAlbum.Tagged -> when (album.tagValue) {
"group_photo" -> Icons.Default.Group to gradientOrange()
"selfie" -> Icons.Default.CameraAlt to gradientPink()
"couple" -> Icons.Default.Favorite to gradientRed()
"family" -> Icons.Default.FamilyRestroom to gradientIndigo()
"friend" -> Icons.Default.People to gradientCyan()
"colleague" -> Icons.Default.BusinessCenter to gradientGray()
"morning" -> Icons.Default.WbSunny to gradientYellow()
"afternoon" -> Icons.Default.LightMode to gradientOrange()
"evening" -> Icons.Default.WbTwilight to gradientOrange()
"night" -> Icons.Default.NightsStay to gradientDarkBlue()
"outdoor" -> Icons.Default.Landscape to gradientGreen()
"indoor" -> Icons.Default.Home to gradientBrown()
"birthday" -> Icons.Default.Cake to gradientPink()
"high_res" -> Icons.Default.HighQuality to gradientGold()
else -> Icons.Default.Label to gradientBlue()
}
is SmartAlbum.Person -> Icons.Default.Person to gradientPurple()
}
}
// Gradient helpers
private fun gradientBlue() = Brush.linearGradient(listOf(Color(0xFF1976D2), Color(0xFF1565C0)))
private fun gradientTeal() = Brush.linearGradient(listOf(Color(0xFF00897B), Color(0xFF00796B)))
private fun gradientGreen() = Brush.linearGradient(listOf(Color(0xFF388E3C), Color(0xFF2E7D32)))
private fun gradientPurple() = Brush.linearGradient(listOf(Color(0xFF7B1FA2), Color(0xFF6A1B9A)))
private fun gradientOrange() = Brush.linearGradient(listOf(Color(0xFFF57C00), Color(0xFFE64A19)))
private fun gradientPink() = Brush.linearGradient(listOf(Color(0xFFD81B60), Color(0xFFC2185B)))
private fun gradientRed() = Brush.linearGradient(listOf(Color(0xFFE53935), Color(0xFFD32F2F)))
private fun gradientIndigo() = Brush.linearGradient(listOf(Color(0xFF3949AB), Color(0xFF303F9F)))
private fun gradientCyan() = Brush.linearGradient(listOf(Color(0xFF00ACC1), Color(0xFF0097A7)))
private fun gradientGray() = Brush.linearGradient(listOf(Color(0xFF616161), Color(0xFF424242)))
private fun gradientYellow() = Brush.linearGradient(listOf(Color(0xFFFDD835), Color(0xFFFBC02D)))
private fun gradientDarkBlue() = Brush.linearGradient(listOf(Color(0xFF283593), Color(0xFF1A237E)))
private fun gradientBrown() = Brush.linearGradient(listOf(Color(0xFF5D4037), Color(0xFF4E342E)))
private fun gradientGold() = Brush.linearGradient(listOf(Color(0xFFFFB300), Color(0xFFFFA000)))

View File

@@ -0,0 +1,302 @@
package com.placeholder.sherpai2.ui.explore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
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 java.util.Calendar
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
private val imageDao: ImageDao,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val personDao: PersonDao,
private val faceRecognitionRepository: FaceRecognitionRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ExploreUiState>(ExploreUiState.Loading)
val uiState: StateFlow<ExploreUiState> = _uiState.asStateFlow()
sealed class ExploreUiState {
object Loading : ExploreUiState()
data class Success(
val smartAlbums: List<SmartAlbum>
) : ExploreUiState()
data class Error(val message: String) : ExploreUiState()
}
init {
loadExploreData()
}
fun loadExploreData() {
viewModelScope.launch {
try {
_uiState.value = ExploreUiState.Loading
val smartAlbums = buildSmartAlbums()
_uiState.value = ExploreUiState.Success(
smartAlbums = smartAlbums
)
} catch (e: Exception) {
_uiState.value = ExploreUiState.Error(
e.message ?: "Failed to load explore data"
)
}
}
}
private suspend fun buildSmartAlbums(): List<SmartAlbum> {
val albums = mutableListOf<SmartAlbum>()
// Time-based albums
albums.add(SmartAlbum.TimeRange.Today)
albums.add(SmartAlbum.TimeRange.ThisWeek)
albums.add(SmartAlbum.TimeRange.ThisMonth)
albums.add(SmartAlbum.TimeRange.LastYear)
// Face-based albums (from system tags)
val groupPhotoTag = tagDao.getByValue("group_photo")
if (groupPhotoTag != null) {
val count = tagDao.getTagUsageCount(groupPhotoTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("group_photo", "Group Photos", count))
}
}
val selfieTag = tagDao.getByValue("selfie")
if (selfieTag != null) {
val count = tagDao.getTagUsageCount(selfieTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("selfie", "Selfies", count))
}
}
val coupleTag = tagDao.getByValue("couple")
if (coupleTag != null) {
val count = tagDao.getTagUsageCount(coupleTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("couple", "Couples", count))
}
}
// Relationship albums
val familyTag = tagDao.getByValue("family")
if (familyTag != null) {
val count = tagDao.getTagUsageCount(familyTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("family", "Family Moments", count))
}
}
val friendTag = tagDao.getByValue("friend")
if (friendTag != null) {
val count = tagDao.getTagUsageCount(friendTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("friend", "With Friends", count))
}
}
val colleagueTag = tagDao.getByValue("colleague")
if (colleagueTag != null) {
val count = tagDao.getTagUsageCount(colleagueTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("colleague", "Work Events", count))
}
}
// Time of day albums
val morningTag = tagDao.getByValue("morning")
if (morningTag != null) {
val count = tagDao.getTagUsageCount(morningTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("morning", "Morning Moments", count))
}
}
val eveningTag = tagDao.getByValue("evening")
if (eveningTag != null) {
val count = tagDao.getTagUsageCount(eveningTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("evening", "Golden Hour", count))
}
}
val nightTag = tagDao.getByValue("night")
if (nightTag != null) {
val count = tagDao.getTagUsageCount(nightTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("night", "Night Life", count))
}
}
// Scene albums
val outdoorTag = tagDao.getByValue("outdoor")
if (outdoorTag != null) {
val count = tagDao.getTagUsageCount(outdoorTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("outdoor", "Outdoor Adventures", count))
}
}
val indoorTag = tagDao.getByValue("indoor")
if (indoorTag != null) {
val count = tagDao.getTagUsageCount(indoorTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("indoor", "Indoor Moments", count))
}
}
// Special occasions
val birthdayTag = tagDao.getByValue("birthday")
if (birthdayTag != null) {
val count = tagDao.getTagUsageCount(birthdayTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("birthday", "Birthdays", count))
}
}
// Quality albums
val highResTag = tagDao.getByValue("high_res")
if (highResTag != null) {
val count = tagDao.getTagUsageCount(highResTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("high_res", "Best Quality", count))
}
}
// Person albums
val persons = personDao.getAllPersons()
persons.forEach { person ->
val stats = faceRecognitionRepository.getPersonFaceStats(person.id)
if (stats != null && stats.taggedPhotoCount > 0) {
albums.add(SmartAlbum.Person(
personId = person.id,
personName = person.name,
imageCount = stats.taggedPhotoCount
))
}
}
return albums
}
/**
* Get images for a specific smart album
*/
suspend fun getImagesForAlbum(album: SmartAlbum): List<ImageEntity> {
return when (album) {
is SmartAlbum.TimeRange.Today -> {
val startOfDay = getStartOfDay()
imageDao.getImagesInRange(startOfDay, System.currentTimeMillis())
}
is SmartAlbum.TimeRange.ThisWeek -> {
val startOfWeek = getStartOfWeek()
imageDao.getImagesInRange(startOfWeek, System.currentTimeMillis())
}
is SmartAlbum.TimeRange.ThisMonth -> {
val startOfMonth = getStartOfMonth()
imageDao.getImagesInRange(startOfMonth, System.currentTimeMillis())
}
is SmartAlbum.TimeRange.LastYear -> {
val oneYearAgo = System.currentTimeMillis() - (365L * 24 * 60 * 60 * 1000)
imageDao.getImagesInRange(oneYearAgo, System.currentTimeMillis())
}
is SmartAlbum.Tagged -> {
val tag = tagDao.getByValue(album.tagValue)
if (tag != null) {
val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f)
imageDao.getImagesByIds(imageIds)
} else {
emptyList()
}
}
is SmartAlbum.Person -> {
faceRecognitionRepository.getImagesForPerson(album.personId)
}
}
}
private fun getStartOfDay(): Long {
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfWeek(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfMonth(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
}
/**
* Smart album types
*/
sealed class SmartAlbum {
abstract val displayName: String
abstract val imageCount: Int
sealed class TimeRange : SmartAlbum() {
data object Today : TimeRange() {
override val displayName = "Today"
override val imageCount = 0 // Calculated dynamically
}
data object ThisWeek : TimeRange() {
override val displayName = "This Week"
override val imageCount = 0
}
data object ThisMonth : TimeRange() {
override val displayName = "This Month"
override val imageCount = 0
}
data object LastYear : TimeRange() {
override val displayName = "Last Year"
override val imageCount = 0
}
}
data class Tagged(
val tagValue: String,
override val displayName: String,
override val imageCount: Int
) : SmartAlbum()
data class Person(
val personId: String,
val personName: String,
override val imageCount: Int
) : SmartAlbum() {
override val displayName = personName
}
}

View File

@@ -14,6 +14,9 @@ import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.data.repository.PersonFaceStats import com.placeholder.sherpai2.data.repository.PersonFaceStats
import com.placeholder.sherpai2.domain.repository.ImageRepository import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.ml.ThresholdStrategy
import com.placeholder.sherpai2.ml.ImageQuality
import com.placeholder.sherpai2.ml.DetectionContext
import com.placeholder.sherpai2.util.DebugFlags import com.placeholder.sherpai2.util.DebugFlags
import com.placeholder.sherpai2.util.DiagnosticLogger import com.placeholder.sherpai2.util.DiagnosticLogger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -29,7 +32,7 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
/** /**
* PersonInventoryViewModel - Single version with feature flags * PersonInventoryViewModel - Enhanced with smart threshold strategy
* *
* Toggle diagnostics in DebugFlags.kt: * Toggle diagnostics in DebugFlags.kt:
* - ENABLE_FACE_RECOGNITION_LOGGING = true/false * - ENABLE_FACE_RECOGNITION_LOGGING = true/false
@@ -53,7 +56,7 @@ class PersonInventoryViewModel @Inject constructor(
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.10f) // Lower for better detection .setMinFaceSize(0.10f)
.build() .build()
FaceDetection.getClient(options) FaceDetection.getClient(options)
} }
@@ -131,13 +134,13 @@ class PersonInventoryViewModel @Inject constructor(
} }
/** /**
* Scan library with optional diagnostic logging * Scan library with SMART threshold selection
*/ */
fun scanLibraryForPerson(personId: String, faceModelId: String) { fun scanLibraryForPerson(personId: String, faceModelId: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) { if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
DiagnosticLogger.i("=== STARTING LIBRARY SCAN ===") DiagnosticLogger.i("=== STARTING LIBRARY SCAN (ENHANCED) ===")
DiagnosticLogger.i("PersonId: $personId") DiagnosticLogger.i("PersonId: $personId")
DiagnosticLogger.i("FaceModelId: $faceModelId") DiagnosticLogger.i("FaceModelId: $faceModelId")
} }
@@ -153,23 +156,7 @@ class PersonInventoryViewModel @Inject constructor(
val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId) val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId)
val trainingCount = faceModel?.trainingImageCount ?: 15 val trainingCount = faceModel?.trainingImageCount ?: 15
// Dynamic threshold based on training data and debug flag
val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) {
when {
trainingCount < 20 -> 0.48f // Very liberal
trainingCount < 30 -> 0.52f // Liberal
else -> 0.58f // Moderate
}
} else {
when {
trainingCount < 20 -> 0.55f // Moderate
trainingCount < 30 -> 0.60f // Conservative
else -> 0.65f // Strict
}
}
DiagnosticLogger.i("Training count: $trainingCount") DiagnosticLogger.i("Training count: $trainingCount")
DiagnosticLogger.i("Using threshold: $scanThreshold")
val allImages = imageRepository.getAllImages().first() val allImages = imageRepository.getAllImages().first()
val totalImages = allImages.size val totalImages = allImages.size
@@ -194,12 +181,42 @@ class PersonInventoryViewModel @Inject constructor(
DiagnosticLogger.d("--- Image ${index + 1}/$totalImages ---") DiagnosticLogger.d("--- Image ${index + 1}/$totalImages ---")
DiagnosticLogger.d("ImageId: ${image.imageId}") DiagnosticLogger.d("ImageId: ${image.imageId}")
// Detect faces with ML Kit
val detectedFaces = detectFacesInImage(image.imageUri) val detectedFaces = detectFacesInImage(image.imageUri)
totalFacesDetected += detectedFaces.size totalFacesDetected += detectedFaces.size
DiagnosticLogger.d("Faces detected: ${detectedFaces.size}") DiagnosticLogger.d("Faces detected: ${detectedFaces.size}")
if (detectedFaces.isNotEmpty()) { if (detectedFaces.isNotEmpty()) {
// ENHANCED: Calculate image quality
val imageQuality = ThresholdStrategy.estimateImageQuality(
width = image.width,
height = image.height
)
// ENHANCED: Estimate detection context
val detectionContext = ThresholdStrategy.estimateDetectionContext(
faceCount = detectedFaces.size,
faceAreaRatio = if (detectedFaces.isNotEmpty()) {
calculateFaceAreaRatio(detectedFaces[0], image.width, image.height)
} else 0f
)
// ENHANCED: Get smart threshold
val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) {
ThresholdStrategy.getLiberalThreshold(trainingCount)
} else {
ThresholdStrategy.getOptimalThreshold(
trainingCount = trainingCount,
imageQuality = imageQuality,
detectionContext = detectionContext
)
}
DiagnosticLogger.d("Quality: $imageQuality, Context: $detectionContext")
DiagnosticLogger.d("Using threshold: $scanThreshold")
// Scan image with smart threshold
val tags = faceRecognitionRepository.scanImage( val tags = faceRecognitionRepository.scanImage(
imageId = image.imageId, imageId = image.imageId,
detectedFaces = detectedFaces, detectedFaces = detectedFaces,
@@ -309,6 +326,19 @@ class PersonInventoryViewModel @Inject constructor(
} }
} }
/**
* Calculate face area ratio (for context detection)
*/
private fun calculateFaceAreaRatio(
face: DetectedFace,
imageWidth: Int,
imageHeight: Int
): Float {
val faceArea = face.boundingBox.width() * face.boundingBox.height()
val imageArea = imageWidth * imageHeight
return faceArea.toFloat() / imageArea.toFloat()
}
suspend fun getPersonImages(personId: String) = suspend fun getPersonImages(personId: String) =
faceRecognitionRepository.getImagesForPerson(personId) faceRecognitionRepository.getImagesForPerson(personId)

View File

@@ -33,11 +33,11 @@ sealed class AppDestinations(
description = "Find photos by tag or person" description = "Find photos by tag or person"
) )
data object Tour : AppDestinations( data object Explore : AppDestinations(
route = AppRoutes.TOUR, route = AppRoutes.EXPLORE,
icon = Icons.Default.Place, icon = Icons.Default.Explore,
label = "Tour", label = "Explore",
description = "Browse by location & time" description = "Browse smart albums"
) )
// ImageDetail is not in drawer (internal navigation only) // ImageDetail is not in drawer (internal navigation only)
@@ -104,7 +104,7 @@ sealed class AppDestinations(
// Photo browsing section // Photo browsing section
val photoDestinations = listOf( val photoDestinations = listOf(
AppDestinations.Search, AppDestinations.Search,
AppDestinations.Tour AppDestinations.Explore
) )
// Face recognition section // Face recognition section
@@ -135,7 +135,7 @@ val allMainDrawerDestinations = photoDestinations + faceRecognitionDestinations
fun getDestinationByRoute(route: String?): AppDestinations? { fun getDestinationByRoute(route: String?): AppDestinations? {
return when (route) { return when (route) {
AppRoutes.SEARCH -> AppDestinations.Search AppRoutes.SEARCH -> AppDestinations.Search
AppRoutes.TOUR -> AppDestinations.Tour AppRoutes.EXPLORE -> AppDestinations.Explore
AppRoutes.INVENTORY -> AppDestinations.Inventory AppRoutes.INVENTORY -> AppDestinations.Inventory
AppRoutes.TRAIN -> AppDestinations.Train AppRoutes.TRAIN -> AppDestinations.Train
AppRoutes.MODELS -> AppDestinations.Models AppRoutes.MODELS -> AppDestinations.Models

View File

@@ -13,12 +13,12 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.placeholder.sherpai2.ui.devscreens.DummyScreen import com.placeholder.sherpai2.ui.devscreens.DummyScreen
import com.placeholder.sherpai2.ui.explore.ExploreScreen
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen
import com.placeholder.sherpai2.ui.search.SearchScreen import com.placeholder.sherpai2.ui.search.SearchScreen
import com.placeholder.sherpai2.ui.search.SearchViewModel import com.placeholder.sherpai2.ui.search.SearchViewModel
import com.placeholder.sherpai2.ui.tour.TourScreen import com.placeholder.sherpai2.ui.tags.TagManagementScreen
import com.placeholder.sherpai2.ui.tour.TourViewModel
import com.placeholder.sherpai2.ui.trainingprep.ImageSelectorScreen import com.placeholder.sherpai2.ui.trainingprep.ImageSelectorScreen
import com.placeholder.sherpai2.ui.trainingprep.ScanResultsScreen import com.placeholder.sherpai2.ui.trainingprep.ScanResultsScreen
import com.placeholder.sherpai2.ui.trainingprep.ScanningState import com.placeholder.sherpai2.ui.trainingprep.ScanningState
@@ -29,9 +29,10 @@ import java.net.URLEncoder
/** /**
* AppNavHost - Main navigation graph * AppNavHost - Main navigation graph
* UPDATED: Added Explore and Tags screens
* *
* Complete flow: * Complete flow:
* - Photo browsing (Search, Tour, Detail) * - Photo browsing (Search, Explore, Detail)
* - Face recognition (Inventory, Train) * - Face recognition (Inventory, Train)
* - Organization (Tags, Upload) * - Organization (Tags, Upload)
* - Settings * - Settings
@@ -72,6 +73,21 @@ fun AppNavHost(
) )
} }
/**
* EXPLORE SCREEN
* Browse smart albums (auto-generated from tags)
*/
composable(AppRoutes.EXPLORE) {
ExploreScreen(
onAlbumClick = { albumType, albumId ->
println("Album clicked: type=$albumType id=$albumId")
// Example future navigation
// navController.navigate("${AppRoutes.ALBUM}/$albumType/$albumId")
}
)
}
/** /**
* IMAGE DETAIL SCREEN * IMAGE DETAIL SCREEN
* Single photo view with metadata * Single photo view with metadata
@@ -94,21 +110,6 @@ fun AppNavHost(
) )
} }
/**
* TOUR SCREEN
* Browse photos by location and time
*/
composable(AppRoutes.TOUR) {
val tourViewModel: TourViewModel = hiltViewModel()
TourScreen(
tourViewModel = tourViewModel,
onImageClick = { imageUri ->
val encodedUri = URLEncoder.encode(imageUri, "UTF-8")
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri")
}
)
}
// ========================================== // ==========================================
// FACE RECOGNITION SYSTEM // FACE RECOGNITION SYSTEM
// ========================================== // ==========================================
@@ -139,7 +140,7 @@ fun AppNavHost(
* *
* Flow: * Flow:
* 1. TrainingScreen (select images button) * 1. TrainingScreen (select images button)
* 2. ImageSelectorScreen (pick 10+ photos) * 2. ImageSelectorScreen (pick 15-50 photos)
* 3. ScanResultsScreen (validation + name input) * 3. ScanResultsScreen (validation + name input)
* 4. Training completes → navigate to Inventory * 4. Training completes → navigate to Inventory
*/ */
@@ -215,13 +216,10 @@ fun AppNavHost(
/** /**
* TAGS SCREEN * TAGS SCREEN
* Manage photo tags (placeholder) * Manage photo tags with auto-tagging features
*/ */
composable(AppRoutes.TAGS) { composable(AppRoutes.TAGS) {
DummyScreen( TagManagementScreen()
title = "Tags",
subtitle = "Organize your photos with tags"
)
} }
/** /**

View File

@@ -11,22 +11,26 @@ package com.placeholder.sherpai2.ui.navigation
* - Keeps NavHost decoupled from icons / labels * - Keeps NavHost decoupled from icons / labels
*/ */
object AppRoutes { object AppRoutes {
const val TOUR = "tour" // Photo browsing
const val SEARCH = "search" const val SEARCH = "search"
const val MODELS = "models" const val EXPLORE = "explore" // UPDATED: Changed from TOUR
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 IMAGE_DETAIL = "IMAGE_DETAIL"
const val CROP_SCREEN = "CROP_SCREEN" // Face recognition
const val INVENTORY = "inv"
const val TRAIN = "train"
const val MODELS = "models"
// Organization
const val TAGS = "tags"
const val UPLOAD = "upload"
// Settings
const val SETTINGS = "settings"
// Internal training flow screens
const val IMAGE_SELECTOR = "Image Selection" const val IMAGE_SELECTOR = "Image Selection"
const val CROP_SCREEN = "CROP_SCREEN"
const val TRAINING_SCREEN = "TRAINING_SCREEN" const val TRAINING_SCREEN = "TRAINING_SCREEN"
const val ScanResultsScreen = "First Scan Results" const val ScanResultsScreen = "First Scan Results"
}
//const val IMAGE_DETAIL = "IMAGE_DETAIL"
}

View File

@@ -14,12 +14,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label 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.material.icons.filled.*
import com.placeholder.sherpai2.ui.navigation.AppRoutes import com.placeholder.sherpai2.ui.navigation.AppRoutes
/** /**
* Beautiful app drawer with sections, gradient header, and polish * Beautiful app drawer with sections, gradient header, and polish
* UPDATED: Tour → Explore
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -98,7 +98,7 @@ fun AppDrawerContent(
val photoItems = listOf( val photoItems = listOf(
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search, "Find photos by tag or person"), DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search, "Find photos by tag or person"),
DrawerItem(AppRoutes.TOUR, "Tour", Icons.Default.Place, "Browse by location & time") DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore, "Browse smart albums")
) )
photoItems.forEach { item -> photoItems.forEach { item ->

View File

@@ -180,7 +180,7 @@ fun MainScreen() {
private fun getScreenTitle(route: String): String { private fun getScreenTitle(route: String): String {
return when (route) { return when (route) {
AppRoutes.SEARCH -> "Search" AppRoutes.SEARCH -> "Search"
AppRoutes.TOUR -> "Explore" // Will be renamed to EXPLORE AppRoutes.EXPLORE -> "Explore" // Will be renamed to EXPLORE
AppRoutes.INVENTORY -> "People" AppRoutes.INVENTORY -> "People"
AppRoutes.TRAIN -> "Train New Person" AppRoutes.TRAIN -> "Train New Person"
AppRoutes.MODELS -> "AI Models" AppRoutes.MODELS -> "AI Models"
@@ -197,7 +197,7 @@ private fun getScreenTitle(route: String): String {
private fun getScreenSubtitle(route: String): String? { private fun getScreenSubtitle(route: String): String? {
return when (route) { return when (route) {
AppRoutes.SEARCH -> "Find photos by tags, people, or date" AppRoutes.SEARCH -> "Find photos by tags, people, or date"
AppRoutes.TOUR -> "Browse your collection" AppRoutes.EXPLORE -> "Browse your collection"
AppRoutes.INVENTORY -> "Trained face models" AppRoutes.INVENTORY -> "Trained face models"
AppRoutes.TRAIN -> "Add a new person to recognize" AppRoutes.TRAIN -> "Add a new person to recognize"
AppRoutes.TAGS -> "Organize your photo collection" AppRoutes.TAGS -> "Organize your photo collection"

View File

@@ -1,8 +1,11 @@
package com.placeholder.sherpai2.ui.search package com.placeholder.sherpai2.ui.search
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.* import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -12,7 +15,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -20,31 +22,41 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.search.components.ImageGridItem import com.placeholder.sherpai2.ui.search.components.ImageGridItem
/** /**
* Beautiful SearchScreen with face tag display * SearchScreen - COMPLETE REDESIGN
* *
* Polish improvements: * Features:
* - Gradient header * - Near-match search ("low" → "low_res")
* - Better stats card * - Quick tag filter chips
* - Smooth animations * - Date range filtering
* - Enhanced visual hierarchy * - Clean person-only display
* - Simple/Verbose toggle
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchScreen( fun SearchScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchViewModel: SearchViewModel, searchViewModel: SearchViewModel,
onImageClick: (String) -> Unit onImageClick: (String) -> Unit,
onAlbumClick: (String) -> Unit = {} // For opening album view
) { ) {
var query by remember { mutableStateOf("") } val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val activeTagFilters by searchViewModel.activeTagFilters.collectAsStateWithLifecycle()
val dateRange by searchViewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by searchViewModel.displayMode.collectAsStateWithLifecycle()
val systemTags by searchViewModel.systemTags.collectAsStateWithLifecycle()
val images by searchViewModel val images by searchViewModel
.searchImagesByTag(query) .searchImages()
.collectAsStateWithLifecycle(initialValue = emptyList()) .collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold( Scaffold { paddingValues ->
topBar = { Column(
// Gradient header modifier = modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Header with gradient
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -60,29 +72,15 @@ fun SearchScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Title // Title
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) { ) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
shadowElevation = 2.dp,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(28.dp)
)
}
}
Column { Column {
Text( Text(
text = "Search Photos", text = "Search Photos",
@@ -90,73 +88,193 @@ fun SearchScreen(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = "Find by tag or person", text = "Near-match • Filters • Smart tags",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
// Simple/Verbose toggle
IconButton(
onClick = { searchViewModel.toggleDisplayMode() }
) {
Icon(
imageVector = if (displayMode == DisplayMode.SIMPLE) {
Icons.Default.ViewList
} else {
Icons.Default.ViewModule
},
contentDescription = "Toggle view mode",
tint = MaterialTheme.colorScheme.primary
)
}
} }
Spacer(modifier = Modifier.height(16.dp))
// Search bar // Search bar
OutlinedTextField( OutlinedTextField(
value = query, value = searchQuery,
onValueChange = { query = it }, onValueChange = { searchViewModel.setSearchQuery(it) },
label = { Text("Search by tag") }, placeholder = { Text("Search... (e.g., 'low', 'gro', 'nig')") },
leadingIcon = { leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null) Icon(Icons.Default.Search, contentDescription = null)
}, },
trailingIcon = { trailingIcon = {
if (query.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
IconButton(onClick = { query = "" }) { IconButton(onClick = { searchViewModel.setSearchQuery("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear") Icon(Icons.Default.Clear, contentDescription = "Clear")
} }
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp)
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface
)
) )
} }
} }
}
) { paddingValues -> // Quick Tag Filters
Column( if (systemTags.isNotEmpty()) {
modifier = modifier Column(
.fillMaxSize() modifier = Modifier
.padding(paddingValues) .fillMaxWidth()
) { .padding(horizontal = 16.dp, vertical = 8.dp)
// Stats bar ) {
if (images.isNotEmpty()) { Row(
StatsBar(images = images) modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Quick Filters",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (activeTagFilters.isNotEmpty()) {
TextButton(onClick = { searchViewModel.clearTagFilters() }) {
Text("Clear all")
}
}
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(systemTags) { tag ->
val isActive = tag.value in activeTagFilters
FilterChip(
selected = isActive,
onClick = { searchViewModel.toggleTagFilter(tag.value) },
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = getTagEmoji(tag.value),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = tag.value.replace("_", " "),
style = MaterialTheme.typography.bodySmall
)
}
},
leadingIcon = if (isActive) {
{ Icon(Icons.Default.Check, null, Modifier.size(16.dp)) }
} else null
)
}
}
}
} }
// Results grid // Date Range Filters
if (images.isEmpty() && query.isBlank()) { LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DateRange.entries) { range ->
val isActive = dateRange == range
FilterChip(
selected = isActive,
onClick = { searchViewModel.setDateRange(range) },
label = { Text(range.displayName) },
leadingIcon = if (isActive) {
{ Icon(Icons.Default.DateRange, null, Modifier.size(16.dp)) }
} else null
)
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
// Results
if (images.isEmpty() && searchQuery.isBlank() && activeTagFilters.isEmpty()) {
EmptySearchState() EmptySearchState()
} else if (images.isEmpty() && query.isNotBlank()) { } else if (images.isEmpty()) {
NoResultsState(query = query) NoResultsState(
query = searchQuery,
hasFilters = activeTagFilters.isNotEmpty() || dateRange != DateRange.ALL_TIME
)
} else { } else {
LazyVerticalGrid( Column {
columns = GridCells.Adaptive(120.dp), // Results header
contentPadding = PaddingValues(12.dp), Row(
verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(12.dp), .fillMaxWidth()
modifier = Modifier.fillMaxSize() .padding(16.dp),
) { horizontalArrangement = Arrangement.SpaceBetween,
items( verticalAlignment = Alignment.CenterVertically
items = images, ) {
key = { it.image.imageId } Text(
) { imageWithFaceTags -> text = "${images.size} ${if (images.size == 1) "photo" else "photos"}",
ImageWithFaceTagsCard( style = MaterialTheme.typography.titleMedium,
imageWithFaceTags = imageWithFaceTags, fontWeight = FontWeight.Bold
onImageClick = onImageClick
) )
// View Album button (if search results can be grouped)
if (activeTagFilters.size == 1 || searchQuery.isNotBlank()) {
TextButton(
onClick = {
val albumTag = activeTagFilters.firstOrNull() ?: searchQuery
onAlbumClick(albumTag)
}
) {
Icon(
Icons.Default.Collections,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("View Album")
}
}
}
// Photo grid
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = images,
key = { it.image.imageId }
) { imageWithFaceTags ->
PhotoCard(
imageWithFaceTags = imageWithFaceTags,
displayMode = displayMode,
onImageClick = onImageClick
)
}
} }
} }
} }
@@ -165,92 +283,103 @@ fun SearchScreen(
} }
/** /**
* Pretty stats bar showing results summary * Photo card with clean person display
*/ */
@Composable @Composable
private fun StatsBar(images: List<ImageWithFaceTags>) { private fun PhotoCard(
val totalFaces = images.sumOf { it.faceTags.size } imageWithFaceTags: ImageWithFaceTags,
val uniquePersons = images.flatMap { it.persons }.distinctBy { it.id }.size displayMode: DisplayMode,
onImageClick: (String) -> Unit
Surface( ) {
modifier = Modifier Card(
.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.padding(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
shape = RoundedCornerShape(16.dp),
shadowElevation = 2.dp
) { ) {
Row( Column {
modifier = Modifier.padding(16.dp), // Image
horizontalArrangement = Arrangement.SpaceEvenly, ImageGridItem(
verticalAlignment = Alignment.CenterVertically image = imageWithFaceTags.image,
) { onClick = { onImageClick(imageWithFaceTags.image.imageUri) }
StatBadge(
icon = Icons.Default.Photo,
label = "Images",
value = images.size.toString()
) )
VerticalDivider( // Person tags
modifier = Modifier.height(40.dp), if (imageWithFaceTags.persons.isNotEmpty()) {
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) when (displayMode) {
) DisplayMode.SIMPLE -> {
// SIMPLE: Just names, no icons, no percentages
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = imageWithFaceTags.persons
.take(3)
.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
DisplayMode.VERBOSE -> {
// VERBOSE: Icons + names + confidence
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
imageWithFaceTags.persons
.take(3)
.forEachIndexed { index, person ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = person.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (index < imageWithFaceTags.faceTags.size) {
val confidence = (imageWithFaceTags.faceTags[index].confidence * 100).toInt()
Text(
text = "$confidence%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
StatBadge( if (imageWithFaceTags.persons.size > 3) {
icon = Icons.Default.Face, Text(
label = "Faces", text = "+${imageWithFaceTags.persons.size - 3} more",
value = totalFaces.toString() style = MaterialTheme.typography.labelSmall,
) color = MaterialTheme.colorScheme.primary
)
if (uniquePersons > 0) { }
VerticalDivider( }
modifier = Modifier.height(40.dp), }
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) }
) }
StatBadge(
icon = Icons.Default.People,
label = "People",
value = uniquePersons.toString()
)
} }
} }
} }
} }
@Composable
private fun StatBadge(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Empty state when no search query
*/
@Composable @Composable
private fun EmptySearchState() { private fun EmptySearchState() {
Box( Box(
@@ -269,12 +398,12 @@ private fun EmptySearchState() {
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
) )
Text( Text(
text = "Search your photos", text = "Search or filter photos",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = "Enter a tag to find photos", text = "Try searching or tapping quick filters",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -282,11 +411,8 @@ private fun EmptySearchState() {
} }
} }
/**
* No results state
*/
@Composable @Composable
private fun NoResultsState(query: String) { private fun NoResultsState(query: String, hasFilters: Boolean) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -303,103 +429,50 @@ private fun NoResultsState(query: String) {
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f) tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
) )
Text( Text(
text = "No results", text = "No results found",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( if (query.isNotBlank()) {
text = "No photos found for \"$query\"", Text(
style = MaterialTheme.typography.bodyMedium, text = "No matches for \"$query\"",
color = MaterialTheme.colorScheme.onSurfaceVariant style = MaterialTheme.typography.bodyMedium,
) color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (hasFilters) {
Text(
text = "Try removing some filters",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} }
} }
/** /**
* Beautiful card showing image with face tags * Get emoji for tag type
*/ */
@Composable private fun getTagEmoji(tagValue: String): String {
private fun ImageWithFaceTagsCard( return when (tagValue) {
imageWithFaceTags: ImageWithFaceTags, "night" -> "🌙"
onImageClick: (String) -> Unit "morning" -> "🌅"
) { "afternoon" -> "☀️"
Card( "evening" -> "🌇"
modifier = Modifier.fillMaxWidth(), "indoor" -> "🏠"
shape = RoundedCornerShape(16.dp), "outdoor" -> "🌲"
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) "group_photo" -> "👥"
) { "selfie" -> "🤳"
Column( "couple" -> "💑"
modifier = Modifier.fillMaxWidth() "family" -> "👨‍👩‍👧"
) { "friend" -> "🤝"
// Image "birthday" -> "🎂"
ImageGridItem( "high_res" -> ""
image = imageWithFaceTags.image, "low_res" -> "📦"
onClick = { onImageClick(imageWithFaceTags.image.imageId) } "landscape" -> "🖼️"
) "portrait" -> "📱"
"square" -> ""
// Face tags else -> "🏷️"
if (imageWithFaceTags.persons.isNotEmpty()) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
imageWithFaceTags.persons.take(3).forEachIndexed { index, person ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = person.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (index < imageWithFaceTags.faceTags.size) {
val confidence = (imageWithFaceTags.faceTags[index].confidence * 100).toInt()
Surface(
shape = RoundedCornerShape(8.dp),
color = if (confidence >= 80) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f)
}
) {
Text(
text = "$confidence%",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
fontWeight = FontWeight.Bold
)
}
}
}
}
if (imageWithFaceTags.persons.size > 3) {
Text(
text = "+${imageWithFaceTags.persons.size - 3} more",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
} }
} }

View File

@@ -1,70 +1,288 @@
package com.placeholder.sherpai2.ui.search package com.placeholder.sherpai2.ui.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.domain.repository.ImageRepository import com.placeholder.sherpai2.domain.repository.ImageRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject import javax.inject.Inject
/** /**
* SearchViewModel * SearchViewModel - COMPLETE REDESIGN
* *
* CLEAN IMPLEMENTATION: * Features:
* - Properly handles Flow types * - Near-match search ("low" → "low_res", "gro" → "group_photo")
* - Fetches face tags for each image * - Date range filtering
* - Returns combined data structure * - Quick tag filters
* - Clean person-only display
* - Simple/Verbose toggle
*/ */
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val faceRecognitionRepository: FaceRecognitionRepository private val faceRecognitionRepository: FaceRecognitionRepository,
private val tagDao: TagDao
) : ViewModel() { ) : ViewModel() {
// Search query with near-match support
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Active tag filters (quick chips)
private val _activeTagFilters = MutableStateFlow<Set<String>>(emptySet())
val activeTagFilters: StateFlow<Set<String>> = _activeTagFilters.asStateFlow()
// Date range filter
private val _dateRange = MutableStateFlow(DateRange.ALL_TIME)
val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow()
// Display mode (simple = names only, verbose = icons + percentages)
private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE)
val displayMode: StateFlow<DisplayMode> = _displayMode.asStateFlow()
// Available system tags for quick filters
private val _systemTags = MutableStateFlow<List<TagEntity>>(emptyList())
val systemTags: StateFlow<List<TagEntity>> = _systemTags.asStateFlow()
init {
loadSystemTags()
}
/** /**
* Search images by tag with face recognition data. * Main search flow - combines query, tag filters, and date range
*
* RETURNS: Flow<List<ImageWithFaceTags>>
* Each image includes its detected faces and person names
*/ */
fun searchImagesByTag(tag: String): Flow<List<ImageWithFaceTags>> { fun searchImages(): Flow<List<ImageWithFaceTags>> {
val imagesFlow = if (tag.isBlank()) { return combine(
imageRepository.getAllImages() _searchQuery,
} else { _activeTagFilters,
imageRepository.findImagesByTag(tag) _dateRange
} ) { query, tagFilters, dateRange ->
Triple(query, tagFilters, dateRange)
}.flatMapLatest { (query, tagFilters, dateRange) ->
// Transform Flow to include face recognition data channelFlow {
return imagesFlow.map { imagesList -> // Get matching tags FIRST (suspend call)
imagesList.map { imageWithEverything -> val matchingTags = if (query.isNotBlank()) {
// Get face tags with person info for this image findMatchingTags(query)
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons( } else {
imageWithEverything.image.imageId emptyList()
) }
ImageWithFaceTags( // Get base images
image = imageWithEverything.image, val imagesFlow = when {
faceTags = tagsWithPersons.map { it.first }, matchingTags.isNotEmpty() -> {
persons = tagsWithPersons.map { it.second } // Search by all matching tags
) combine(matchingTags.map { tag ->
imageRepository.findImagesByTag(tag.value)
}) { results ->
results.flatMap { it }.distinctBy { it.image.imageId }
}
}
tagFilters.isNotEmpty() -> {
// Filter by active tags
combine(tagFilters.map { tagValue ->
imageRepository.findImagesByTag(tagValue)
}) { results ->
results.flatMap { it }.distinctBy { it.image.imageId }
}
}
else -> imageRepository.getAllImages()
}
// Apply date filtering and add face data
imagesFlow.collect { imagesList ->
val filtered = imagesList
.filter { imageWithEverything ->
isInDateRange(imageWithEverything.image.capturedAt, dateRange)
}
.map { imageWithEverything ->
// Get face tags with person info
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(
imageWithEverything.image.imageId
)
ImageWithFaceTags(
image = imageWithEverything.image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
.sortedByDescending { it.image.capturedAt }
send(filtered)
}
} }
} }
} }
/**
* Near-match search: "low" matches "low_res", "gro" matches "group_photo"
*/
private suspend fun findMatchingTags(query: String): List<TagEntity> {
val normalizedQuery = query.trim().lowercase()
// Get all system tags
val allTags = tagDao.getByType("SYSTEM")
// Find tags that contain the query or match it closely
return allTags.filter { tag ->
val tagValue = tag.value.lowercase()
// Exact match
tagValue == normalizedQuery ||
// Contains match
tagValue.contains(normalizedQuery) ||
// Starts with match
tagValue.startsWith(normalizedQuery) ||
// Fuzzy match (remove underscores and compare)
tagValue.replace("_", "").contains(normalizedQuery.replace("_", ""))
}.sortedBy { tag ->
// Sort by relevance: exact > starts with > contains
when {
tag.value.lowercase() == normalizedQuery -> 0
tag.value.lowercase().startsWith(normalizedQuery) -> 1
else -> 2
}
}
}
/**
* Load available system tags for quick filters
*/
private fun loadSystemTags() {
viewModelScope.launch {
val tags = tagDao.getByType("SYSTEM")
// Get usage counts for all tags
val tagsWithUsage = tags.map { tag ->
tag to tagDao.getTagUsageCount(tag.tagId)
}
// Sort by most commonly used
val sortedTags = tagsWithUsage
.sortedByDescending { (_, usageCount) -> usageCount }
.take(12) // Show top 12 most used tags
.map { (tag, _) -> tag }
_systemTags.value = sortedTags
}
}
/**
* Update search query
*/
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
/**
* Toggle a tag filter
*/
fun toggleTagFilter(tagValue: String) {
_activeTagFilters.value = if (tagValue in _activeTagFilters.value) {
_activeTagFilters.value - tagValue
} else {
_activeTagFilters.value + tagValue
}
}
/**
* Clear all tag filters
*/
fun clearTagFilters() {
_activeTagFilters.value = emptySet()
}
/**
* Set date range filter
*/
fun setDateRange(range: DateRange) {
_dateRange.value = range
}
/**
* Toggle display mode (simple/verbose)
*/
fun toggleDisplayMode() {
_displayMode.value = when (_displayMode.value) {
DisplayMode.SIMPLE -> DisplayMode.VERBOSE
DisplayMode.VERBOSE -> DisplayMode.SIMPLE
}
}
/**
* Check if timestamp is in date range
*/
private fun isInDateRange(timestamp: Long, range: DateRange): Boolean {
return when (range) {
DateRange.ALL_TIME -> true
DateRange.TODAY -> isToday(timestamp)
DateRange.THIS_WEEK -> isThisWeek(timestamp)
DateRange.THIS_MONTH -> isThisMonth(timestamp)
DateRange.THIS_YEAR -> isThisYear(timestamp)
}
}
private fun isToday(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == date.get(Calendar.DAY_OF_YEAR)
}
private fun isThisWeek(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.WEEK_OF_YEAR) == date.get(Calendar.WEEK_OF_YEAR)
}
private fun isThisMonth(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.MONTH) == date.get(Calendar.MONTH)
}
private fun isThisYear(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR)
}
} }
/** /**
* Data class containing image with face recognition data * Data class containing image with face recognition data
*
* @property image The image entity
* @property faceTags Face tags detected in this image
* @property persons Person entities (parallel to faceTags)
*/ */
data class ImageWithFaceTags( data class ImageWithFaceTags(
val image: ImageEntity, val image: ImageEntity,
val faceTags: List<PhotoFaceTagEntity>, val faceTags: List<PhotoFaceTagEntity>,
val persons: List<PersonEntity> val persons: List<PersonEntity>
) )
/**
* Date range filters
*/
enum class DateRange(val displayName: String) {
ALL_TIME("All Time"),
TODAY("Today"),
THIS_WEEK("This Week"),
THIS_MONTH("This Month"),
THIS_YEAR("This Year")
}
/**
* Display modes for photo tags
*/
enum class DisplayMode {
SIMPLE, // Just person names
VERBOSE // Names + icons + confidence percentages
}

View File

@@ -0,0 +1,624 @@
package com.placeholder.sherpai2.ui.tags
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
@Composable
fun TagManagementScreen(
viewModel: TagManagementViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val scanningState by viewModel.scanningState.collectAsState()
var showAddTagDialog by remember { mutableStateOf(false) }
var showScanMenu by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
Scaffold(
floatingActionButton = {
// Single extended FAB with dropdown menu
var showMenu by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Dropdown menu for scan options
if (showMenu) {
Card(
modifier = Modifier.width(180.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column {
ListItem(
headlineContent = { Text("Scan All", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.AutoFixHigh,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForAllTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Base Tags", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.PhotoCamera,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForBaseTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Relationships", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.People,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForRelationshipTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Birthdays", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.Cake,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForBirthdayTags()
showMenu = false
}
)
}
}
}
// Main FAB
ExtendedFloatingActionButton(
onClick = { showMenu = !showMenu },
icon = {
Icon(
if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh,
"Scan"
)
},
text = { Text(if (showMenu) "Close" else "Scan Tags") }
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Stats Bar
StatsBar(uiState)
// Search Bar
SearchBar(
searchQuery = searchQuery,
onSearchChange = {
searchQuery = it
viewModel.searchTags(it)
}
)
// Scanning Progress
AnimatedVisibility(
visible = scanningState !is TagManagementViewModel.TagScanningState.Idle,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
ScanningProgress(scanningState, viewModel)
}
// Tag List
when (val state = uiState) {
is TagManagementViewModel.TagUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is TagManagementViewModel.TagUiState.Success -> {
TagList(
tags = state.tags,
onDeleteTag = { viewModel.deleteTag(it) }
)
}
is TagManagementViewModel.TagUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// Add Tag Dialog
if (showAddTagDialog) {
AddTagDialog(
onDismiss = { showAddTagDialog = false },
onConfirm = { tagName ->
viewModel.createUserTag(tagName)
showAddTagDialog = false
}
)
}
// Scan Menu
if (showScanMenu) {
ScanMenuDialog(
onDismiss = { showScanMenu = false },
onScanSelected = { scanType ->
when (scanType) {
TagManagementViewModel.ScanType.BASE_TAGS -> viewModel.scanForBaseTags()
TagManagementViewModel.ScanType.RELATIONSHIP_TAGS -> viewModel.scanForRelationshipTags()
TagManagementViewModel.ScanType.BIRTHDAY_TAGS -> viewModel.scanForBirthdayTags()
TagManagementViewModel.ScanType.SCENE_TAGS -> viewModel.scanForSceneTags()
TagManagementViewModel.ScanType.ALL -> viewModel.scanForAllTags()
}
showScanMenu = false
}
)
}
}
@Composable
private fun StatsBar(uiState: TagManagementViewModel.TagUiState) {
if (uiState is TagManagementViewModel.TagUiState.Success) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceAround
) {
StatItem("Total", uiState.totalTags.toString(), Icons.Default.Label)
StatItem("System", uiState.systemTags.toString(), Icons.Default.AutoAwesome)
StatItem("User", uiState.userTags.toString(), Icons.Default.PersonOutline)
}
}
}
}
@Composable
private fun StatItem(label: String, value: String, icon: ImageVector) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SearchBar(
searchQuery: String,
onSearchChange: (String) -> Unit
) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search tags...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchChange("") }) {
Icon(Icons.Default.Clear, "Clear")
}
}
},
singleLine = true
)
}
@Composable
private fun ScanningProgress(
scanningState: TagManagementViewModel.TagScanningState,
viewModel: TagManagementViewModel
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
when (scanningState) {
is TagManagementViewModel.TagScanningState.Scanning -> {
Text(
text = "Scanning: ${scanningState.scanType.name.replace("_", " ")}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { scanningState.progress.toFloat() / scanningState.total.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${scanningState.progress} / ${scanningState.total} images",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Tags applied: ${scanningState.tagsApplied}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
is TagManagementViewModel.TagScanningState.Complete -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "✓ Scan Complete",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "${scanningState.tagsApplied} tags applied to ${scanningState.imagesProcessed} images",
style = MaterialTheme.typography.bodySmall
)
}
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
}
}
}
is TagManagementViewModel.TagScanningState.Error -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Error: ${scanningState.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
}
}
}
else -> { /* Idle - don't show */ }
}
}
}
}
@Composable
private fun TagList(
tags: List<TagWithUsage>,
onDeleteTag: (String) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tags, key = { it.tagId }) { tag ->
TagListItem(tag, onDeleteTag)
}
}
}
@Composable
private fun TagListItem(
tag: TagWithUsage,
onDeleteTag: (String) -> Unit
) {
var showDeleteConfirm by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { /* TODO: Navigate to images with this tag */ }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Tag type icon
Icon(
imageVector = if (tag.type == "SYSTEM") Icons.Default.AutoAwesome else Icons.Default.Label,
contentDescription = null,
tint = if (tag.type == "SYSTEM")
MaterialTheme.colorScheme.secondary
else
MaterialTheme.colorScheme.primary
)
Column {
Text(
text = tag.value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = if (tag.type == "SYSTEM") "System tag" else "User tag",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Usage count badge
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = tag.usageCount.toString(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
// Delete button (only for user tags)
if (tag.type == "GENERIC") {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete tag",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete Tag?") },
text = { Text("Are you sure you want to delete '${tag.value}'? This will remove it from ${tag.usageCount} images.") },
confirmButton = {
TextButton(
onClick = {
onDeleteTag(tag.tagId)
showDeleteConfirm = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun AddTagDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var tagName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Tag") },
text = {
OutlinedTextField(
value = tagName,
onValueChange = { tagName = it },
label = { Text("Tag name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(tagName) },
enabled = tagName.isNotBlank()
) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun ScanMenuDialog(
onDismiss: () -> Unit,
onScanSelected: (TagManagementViewModel.ScanType) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Scan for Tags") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ScanOption(
title = "Base Tags",
description = "Face count, orientation, time, quality",
icon = Icons.Default.PhotoCamera,
onClick = { onScanSelected(TagManagementViewModel.ScanType.BASE_TAGS) }
)
ScanOption(
title = "Relationship Tags",
description = "Family, friends, colleagues",
icon = Icons.Default.People,
onClick = { onScanSelected(TagManagementViewModel.ScanType.RELATIONSHIP_TAGS) }
)
ScanOption(
title = "Birthday Tags",
description = "Photos near birthdays",
icon = Icons.Default.Cake,
onClick = { onScanSelected(TagManagementViewModel.ScanType.BIRTHDAY_TAGS) }
)
ScanOption(
title = "Scene Tags",
description = "Indoor/outdoor detection",
icon = Icons.Default.Landscape,
onClick = { onScanSelected(TagManagementViewModel.ScanType.SCENE_TAGS) }
)
Divider()
ScanOption(
title = "Scan All",
description = "Run all scans",
icon = Icons.Default.AutoFixHigh,
onClick = { onScanSelected(TagManagementViewModel.ScanType.ALL) }
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun ScanOption(
title: String,
description: String,
icon: ImageVector,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -0,0 +1,398 @@
package com.placeholder.sherpai2.ui.tags
import android.app.Application
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.data.service.AutoTaggingService
import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.util.DiagnosticLogger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
@HiltViewModel
class TagManagementViewModel @Inject constructor(
application: Application,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val imageRepository: ImageRepository,
private val autoTaggingService: AutoTaggingService
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow<TagUiState>(TagUiState.Loading)
val uiState: StateFlow<TagUiState> = _uiState.asStateFlow()
private val _scanningState = MutableStateFlow<TagScanningState>(TagScanningState.Idle)
val scanningState: StateFlow<TagScanningState> = _scanningState.asStateFlow()
private val faceDetector by lazy {
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.10f)
.build()
FaceDetection.getClient(options)
}
sealed class TagUiState {
object Loading : TagUiState()
data class Success(
val tags: List<TagWithUsage>,
val totalTags: Int,
val systemTags: Int,
val userTags: Int
) : TagUiState()
data class Error(val message: String) : TagUiState()
}
sealed class TagScanningState {
object Idle : TagScanningState()
data class Scanning(
val scanType: ScanType,
val progress: Int,
val total: Int,
val tagsApplied: Int,
val currentImage: String = ""
) : TagScanningState()
data class Complete(
val scanType: ScanType,
val imagesProcessed: Int,
val tagsApplied: Int,
val newTagsCreated: Int = 0
) : TagScanningState()
data class Error(val message: String) : TagScanningState()
}
enum class ScanType {
BASE_TAGS, // Face count, orientation, resolution, time-of-day
RELATIONSHIP_TAGS, // Family, friend, colleague from person entities
BIRTHDAY_TAGS, // Birthday tags for DOB matches
SCENE_TAGS, // Indoor/outdoor estimation
ALL // Run all scans
}
init {
loadTags()
}
fun loadTags() {
viewModelScope.launch {
try {
_uiState.value = TagUiState.Loading
val tagsWithUsage = tagDao.getMostUsedTags(1000) // Get all tags
val systemTags = tagsWithUsage.count { it.type == "SYSTEM" }
val userTags = tagsWithUsage.count { it.type == "GENERIC" }
_uiState.value = TagUiState.Success(
tags = tagsWithUsage,
totalTags = tagsWithUsage.size,
systemTags = systemTags,
userTags = userTags
)
} catch (e: Exception) {
_uiState.value = TagUiState.Error(
e.message ?: "Failed to load tags"
)
}
}
}
fun createUserTag(tagName: String) {
viewModelScope.launch {
try {
val trimmedName = tagName.trim().lowercase()
if (trimmedName.isEmpty()) {
_uiState.value = TagUiState.Error("Tag name cannot be empty")
return@launch
}
// Check if tag already exists
val existing = tagDao.getByValue(trimmedName)
if (existing != null) {
_uiState.value = TagUiState.Error("Tag '$trimmedName' already exists")
return@launch
}
val newTag = TagEntity.createUserTag(trimmedName)
tagDao.insert(newTag)
loadTags()
} catch (e: Exception) {
_uiState.value = TagUiState.Error(
"Failed to create tag: ${e.message}"
)
}
}
}
fun deleteTag(tagId: String) {
viewModelScope.launch {
try {
tagDao.delete(tagId)
loadTags()
} catch (e: Exception) {
_uiState.value = TagUiState.Error(
"Failed to delete tag: ${e.message}"
)
}
}
}
fun searchTags(query: String) {
viewModelScope.launch {
try {
val results = if (query.isBlank()) {
tagDao.getMostUsedTags(1000)
} else {
tagDao.searchTagsWithUsage(query, 100)
}
val systemTags = results.count { it.type == "SYSTEM" }
val userTags = results.count { it.type == "GENERIC" }
_uiState.value = TagUiState.Success(
tags = results,
totalTags = results.size,
systemTags = systemTags,
userTags = userTags
)
} catch (e: Exception) {
_uiState.value = TagUiState.Error("Search failed: ${e.message}")
}
}
}
// ======================
// AUTO-TAGGING SCANS
// ======================
/**
* Scan library for base tags (face count, orientation, time, quality, scene)
*/
fun scanForBaseTags() {
performScan(ScanType.BASE_TAGS)
}
/**
* Scan for relationship tags (family, friend, colleague)
*/
fun scanForRelationshipTags() {
performScan(ScanType.RELATIONSHIP_TAGS)
}
/**
* Scan for birthday tags
*/
fun scanForBirthdayTags() {
performScan(ScanType.BIRTHDAY_TAGS)
}
/**
* Scan for scene tags (indoor/outdoor)
*/
fun scanForSceneTags() {
performScan(ScanType.SCENE_TAGS)
}
/**
* Scan for ALL tags
*/
fun scanForAllTags() {
performScan(ScanType.ALL)
}
private fun performScan(scanType: ScanType) {
viewModelScope.launch {
try {
DiagnosticLogger.i("=== STARTING TAG SCAN: $scanType ===")
_scanningState.value = TagScanningState.Scanning(
scanType = scanType,
progress = 0,
total = 0,
tagsApplied = 0
)
val allImages = imageRepository.getAllImages().first()
var tagsApplied = 0
var newTagsCreated = 0
DiagnosticLogger.i("Processing ${allImages.size} images")
allImages.forEachIndexed { index, imageWithEverything ->
val image = imageWithEverything.image
_scanningState.value = TagScanningState.Scanning(
scanType = scanType,
progress = index + 1,
total = allImages.size,
tagsApplied = tagsApplied,
currentImage = image.imageId.take(8)
)
when (scanType) {
ScanType.BASE_TAGS -> {
tagsApplied += scanImageForBaseTags(image.imageUri, image)
}
ScanType.SCENE_TAGS -> {
tagsApplied += scanImageForSceneTags(image.imageUri, image)
}
ScanType.RELATIONSHIP_TAGS -> {
// Handled at person level, not per-image
}
ScanType.BIRTHDAY_TAGS -> {
// Handled at person level, not per-image
}
ScanType.ALL -> {
tagsApplied += scanImageForBaseTags(image.imageUri, image)
tagsApplied += scanImageForSceneTags(image.imageUri, image)
}
}
}
// Handle person-level scans
if (scanType == ScanType.RELATIONSHIP_TAGS || scanType == ScanType.ALL) {
DiagnosticLogger.i("Scanning relationship tags...")
tagsApplied += autoTaggingService.autoTagAllRelationships()
}
if (scanType == ScanType.BIRTHDAY_TAGS || scanType == ScanType.ALL) {
DiagnosticLogger.i("Scanning birthday tags...")
tagsApplied += autoTaggingService.autoTagAllBirthdays(daysRange = 3)
}
DiagnosticLogger.i("=== SCAN COMPLETE ===")
DiagnosticLogger.i("Images processed: ${allImages.size}")
DiagnosticLogger.i("Tags applied: $tagsApplied")
_scanningState.value = TagScanningState.Complete(
scanType = scanType,
imagesProcessed = allImages.size,
tagsApplied = tagsApplied,
newTagsCreated = newTagsCreated
)
loadTags()
} catch (e: Exception) {
DiagnosticLogger.e("Scan failed", e)
_scanningState.value = TagScanningState.Error(
"Scan failed: ${e.message}"
)
}
}
}
private suspend fun scanImageForBaseTags(
imageUri: String,
image: com.placeholder.sherpai2.data.local.entity.ImageEntity
): Int = withContext(Dispatchers.Default) {
try {
val uri = Uri.parse(imageUri)
val inputStream = getApplication<Application>().contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) return@withContext 0
// Detect faces
val detectedFaces = detectFaces(bitmap)
// Auto-tag with base tags
autoTaggingService.autoTagImage(image, bitmap, detectedFaces)
} catch (e: Exception) {
DiagnosticLogger.e("Base tag scan failed for $imageUri", e)
0
}
}
private suspend fun scanImageForSceneTags(
imageUri: String,
image: com.placeholder.sherpai2.data.local.entity.ImageEntity
): Int = withContext(Dispatchers.Default) {
try {
val uri = Uri.parse(imageUri)
val inputStream = getApplication<Application>().contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) return@withContext 0
// Only auto-tag scene tags (indoor/outdoor already included in autoTagImage)
// This is a subset of base tags, so we don't need separate logic
0
} catch (e: Exception) {
DiagnosticLogger.e("Scene tag scan failed for $imageUri", e)
0
}
}
private suspend fun detectFaces(bitmap: android.graphics.Bitmap): List<DetectedFace> = withContext(Dispatchers.Default) {
try {
val image = InputImage.fromBitmap(bitmap, 0)
val faces = faceDetector.process(image).await()
faces.mapNotNull { face ->
val boundingBox = face.boundingBox
val croppedFace = try {
val left = boundingBox.left.coerceAtLeast(0)
val top = boundingBox.top.coerceAtLeast(0)
val width = boundingBox.width().coerceAtMost(bitmap.width - left)
val height = boundingBox.height().coerceAtMost(bitmap.height - top)
if (width > 0 && height > 0) {
android.graphics.Bitmap.createBitmap(bitmap, left, top, width, height)
} else {
null
}
} catch (e: Exception) {
null
}
if (croppedFace != null) {
DetectedFace(
croppedBitmap = croppedFace,
boundingBox = boundingBox
)
} else {
null
}
}
} catch (e: Exception) {
emptyList()
}
}
fun resetScanningState() {
_scanningState.value = TagScanningState.Idle
}
override fun onCleared() {
super.onCleared()
faceDetector.close()
}
}