Pre UI Sweep

Refactor of the SearchScreen and ImageWithEverything.kt to use include and exlcude filtering

//TODO remove tags easy (versus exlude switch but both are needed)
//SearchScreen still needs export to collage TBD
This commit is contained in:
genki
2026-01-12 16:21:33 -05:00
parent 0f6c9060bf
commit fe50eb245c
13 changed files with 2061 additions and 2685 deletions

View File

@@ -1,23 +1,46 @@
package com.placeholder.sherpai2.data.local.model
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import com.placeholder.sherpai2.data.local.entity.*
/**
* ImageWithEverything - Fully hydrated image with ALL relationships
*
* Room loads this in ONE query using @Transaction!
* NO N+1 problem - all tags and face tags loaded together
*
* Usage:
* - ImageAggregateDao.observeAllImagesWithEverything()
* - Search, Explore, Albums
*/
data class ImageWithEverything(
@Embedded
val image: ImageEntity,
/**
* Tags for this image (via image_tags join table)
* Room automatically joins through ImageTagEntity
*/
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
entityColumn = "tagId",
associateBy = Junction(
value = ImageTagEntity::class,
parentColumn = "imageId",
entityColumn = "tagId"
)
)
val tags: List<ImageTagEntity>,
val tags: List<TagEntity>,
/**
* Face tags for this image
* Room automatically loads all PhotoFaceTagEntity for this imageId
*/
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val events: List<ImageEventEntity>
)
val faceTags: List<PhotoFaceTagEntity>
)

View File

@@ -12,7 +12,6 @@ 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
@@ -25,8 +24,8 @@ import javax.inject.Inject
* Features:
* - Search within album
* - Date filtering
* - Simple/Verbose toggle
* - Album stats
* - Export functionality
*/
@HiltViewModel
class AlbumViewModel @Inject constructor(
@@ -54,10 +53,6 @@ class AlbumViewModel @Inject constructor(
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()
}
@@ -93,7 +88,7 @@ class AlbumViewModel @Inject constructor(
combine(
_searchQuery,
_dateRange
) { query, dateRange ->
) { query: String, dateRange: DateRange ->
Pair(query, dateRange)
}.collectLatest { (query, dateRange) ->
val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f)
@@ -119,7 +114,7 @@ class AlbumViewModel @Inject constructor(
.distinctBy { it.id }
_uiState.value = AlbumUiState.Success(
albumName = tag.value.replace("_", " ").capitalize(),
albumName = tag.value.replace("_", " ").replaceFirstChar { it.uppercase() },
albumType = "Tag",
photos = imagesWithFaces,
personCount = uniquePersons.size,
@@ -138,7 +133,7 @@ class AlbumViewModel @Inject constructor(
combine(
_searchQuery,
_dateRange
) { query, dateRange ->
) { query: String, dateRange: DateRange ->
Pair(query, dateRange)
}.collectLatest { (query, dateRange) ->
val images = faceRecognitionRepository.getImagesForPerson(albumId)
@@ -184,7 +179,7 @@ class AlbumViewModel @Inject constructor(
combine(
_searchQuery,
_dateRange
) { query, _ ->
) { query: String, _: DateRange ->
query
}.collectLatest { query ->
val images = imageDao.getImagesInRange(startTime, endTime)
@@ -224,13 +219,6 @@ class AlbumViewModel @Inject constructor(
_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
@@ -311,10 +299,6 @@ class AlbumViewModel @Inject constructor(
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}
}
sealed class AlbumUiState {

View File

@@ -1,11 +1,9 @@
package com.placeholder.sherpai2.ui.album
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.*
@@ -13,23 +11,24 @@ 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 coil.compose.AsyncImage
import com.placeholder.sherpai2.ui.search.DateRange
import com.placeholder.sherpai2.ui.search.DisplayMode
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
/**
* AlbumViewScreen - UPDATED with clickable images
* AlbumViewScreen - CLEAN VERSION with Export
*
* Changes:
* - PhotoCard now clickable
* - Passes onImageClick to ImageGridItem
* - Entire card surface clickable as backup
* REMOVED:
* - DisplayMode toggle
* - Verbose person tags
*
* ADDED:
* - Export menu (Folder, Zip, Collage)
* - Clean simple layout
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -41,7 +40,8 @@ fun AlbumViewScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val dateRange by viewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by viewModel.displayMode.collectAsStateWithLifecycle()
var showExportMenu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -73,15 +73,9 @@ fun AlbumViewScreen(
}
},
actions = {
IconButton(onClick = { viewModel.toggleDisplayMode() }) {
Icon(
imageVector = if (displayMode == DisplayMode.SIMPLE) {
Icons.Default.ViewList
} else {
Icons.Default.ViewModule
},
contentDescription = "Toggle view"
)
// Export button
IconButton(onClick = { showExportMenu = true }) {
Icon(Icons.Default.FileDownload, "Export")
}
}
)
@@ -127,7 +121,6 @@ fun AlbumViewScreen(
state = state,
searchQuery = searchQuery,
dateRange = dateRange,
displayMode = displayMode,
onSearchChange = { viewModel.setSearchQuery(it) },
onDateRangeChange = { viewModel.setDateRange(it) },
onImageClick = onImageClick,
@@ -136,6 +129,33 @@ fun AlbumViewScreen(
}
}
}
// Export menu dialog
if (showExportMenu) {
ExportDialog(
albumName = when (val state = uiState) {
is AlbumUiState.Success -> state.albumName
else -> "Album"
},
photoCount = when (val state = uiState) {
is AlbumUiState.Success -> state.photos.size
else -> 0
},
onDismiss = { showExportMenu = false },
onExportToFolder = {
// TODO: Implement folder export
showExportMenu = false
},
onExportToZip = {
// TODO: Implement zip export
showExportMenu = false
},
onExportToCollage = {
// TODO: Implement collage export
showExportMenu = false
}
)
}
}
@Composable
@@ -143,7 +163,6 @@ private fun AlbumContent(
state: AlbumUiState.Success,
searchQuery: String,
dateRange: DateRange,
displayMode: DisplayMode,
onSearchChange: (String) -> Unit,
onDateRangeChange: (DateRange) -> Unit,
onImageClick: (String) -> Unit,
@@ -206,7 +225,8 @@ private fun AlbumContent(
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DateRange.entries) { range ->
items(DateRange.entries.size) { index ->
val range = DateRange.entries[index]
val isActive = dateRange == range
FilterChip(
selected = isActive,
@@ -244,7 +264,6 @@ private fun AlbumContent(
) { photo ->
PhotoCard(
photo = photo,
displayMode = displayMode,
onImageClick = onImageClick
)
}
@@ -254,7 +273,11 @@ private fun AlbumContent(
}
@Composable
private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) {
private fun StatItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -279,86 +302,180 @@ private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, labe
}
/**
* PhotoCard - UPDATED: Now fully clickable
* PhotoCard - CLEAN VERSION: Simple image + person names
*/
@Composable
private fun PhotoCard(
photo: AlbumPhoto,
displayMode: DisplayMode,
onImageClick: (String) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onImageClick(photo.image.imageUri) }, // ✅ ENTIRE CARD CLICKABLE
.aspectRatio(1f)
.clickable { onImageClick(photo.image.imageUri) },
shape = RoundedCornerShape(12.dp)
) {
Column {
// Image (also clickable via ImageGridItem)
ImageGridItem(
image = photo.image,
onClick = { onImageClick(photo.image.imageUri) } // ✅ IMAGE CLICKABLE
Box {
// Image
AsyncImage(
model = photo.image.imageUri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
// Person tags
// Person names overlay (if any)
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
)
}
}
}
}
}
}
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
) {
Text(
text = photo.persons.take(2).joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
/**
* Export Dialog
*/
@Composable
private fun ExportDialog(
albumName: String,
photoCount: Int,
onDismiss: () -> Unit,
onExportToFolder: () -> Unit,
onExportToZip: () -> Unit,
onExportToCollage: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.FileDownload, null) },
title = { Text("Export Album") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"$photoCount photos from \"$albumName\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Export to Folder
ExportOption(
icon = Icons.Default.Folder,
title = "Export to Folder",
description = "Save all photos to a folder",
onClick = onExportToFolder
)
// Export to Zip
ExportOption(
icon = Icons.Default.FolderZip,
title = "Export as ZIP",
description = "Create a compressed archive",
onClick = onExportToZip
)
// Export to Collage (placeholder)
ExportOption(
icon = Icons.Default.GridView,
title = "Create Collage",
description = "Coming soon!",
onClick = onExportToCollage,
enabled = false
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun ExportOption(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
description: String,
onClick: () -> Unit,
enabled: Boolean = true
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = if (enabled) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primary.copy(
alpha = if (enabled) 1f else 0.5f
),
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
}
)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (enabled) 1f else 0.5f
)
)
}
if (enabled) {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -23,57 +23,28 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
/**
* ExploreScreen - REDESIGNED
* CLEANED ExploreScreen - No gradient header banner
*
* Removed:
* - Gradient header box (lines 46-75) that created banner effect
* - "Explore" title (MainScreen shows it)
*
* Features:
* - Rectangular album cards (more compact)
* - Rectangular album cards (compact)
* - Stories section (recent highlights)
* - Clickable navigation to AlbumViewScreen
* - Beautiful gradients and icons
* - Mobile-friendly scrolling
*/
@Composable
fun ExploreScreen(
onAlbumClick: (albumType: String, albumId: String) -> Unit,
viewModel: ExploreViewModel = hiltViewModel()
viewModel: ExploreViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
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
)
}
}
Box(modifier = modifier.fillMaxSize()) {
when (val state = uiState) {
is ExploreViewModel.ExploreUiState.Loading -> {
Box(
@@ -83,12 +54,18 @@ fun ExploreScreen(
CircularProgressIndicator()
}
}
is ExploreViewModel.ExploreUiState.Success -> {
ExploreContent(
smartAlbums = state.smartAlbums,
onAlbumClick = onAlbumClick
)
if (state.smartAlbums.isEmpty()) {
EmptyExploreView()
} else {
ExploreContent(
smartAlbums = state.smartAlbums,
onAlbumClick = onAlbumClick
)
}
}
is ExploreViewModel.ExploreUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
@@ -96,17 +73,25 @@ fun ExploreScreen(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = "Error Loading Albums",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = state.message,
color = MaterialTheme.colorScheme.error
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
@@ -115,6 +100,9 @@ fun ExploreScreen(
}
}
/**
* Main content - scrollable album sections
*/
@Composable
private fun ExploreContent(
smartAlbums: List<SmartAlbum>,
@@ -127,10 +115,13 @@ private fun ExploreContent(
) {
// Stories Section (Recent Highlights)
item {
StoriesSection(
albums = smartAlbums.filter { it.imageCount > 0 }.take(10),
onAlbumClick = onAlbumClick
)
val storyAlbums = smartAlbums.filter { it.imageCount > 0 }.take(10)
if (storyAlbums.isNotEmpty()) {
StoriesSection(
albums = storyAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Time-based Albums
@@ -225,7 +216,7 @@ private fun ExploreContent(
}
/**
* Stories section - Instagram-style circular highlights
* Stories section - circular album previews
*/
@Composable
private fun StoriesSection(
@@ -294,7 +285,8 @@ private fun StoryCircle(
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
modifier = Modifier.width(80.dp),
fontWeight = FontWeight.Medium
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Text(
@@ -342,7 +334,7 @@ private fun AlbumSection(
}
/**
* Rectangular album card - more compact than square
* Rectangular album card - compact design
*/
@Composable
private fun AlbumCard(
@@ -398,6 +390,44 @@ private fun AlbumCard(
}
}
/**
* Empty state
*/
@Composable
private fun EmptyExploreView() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.PhotoAlbum,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Text(
"No Albums Yet",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"Add photos to your collection to see smart albums",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
/**
* Get navigation parameters for album
*/

View File

@@ -14,24 +14,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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 java.text.SimpleDateFormat
import java.util.*
/**
* PersonInventoryScreen - Manage trained face models
* CLEANED PersonInventoryScreen - No duplicate header
*
* Features:
* - List all trained persons
* - View stats
* - DELETE models
* - SCAN LIBRARY to find person in all photos (NEW!)
* Removed:
* - Scaffold wrapper
* - TopAppBar (was creating banner)
* - "Trained People" title (MainScreen shows it)
*
* FIXED to match ViewModel exactly:
* - Uses InventoryUiState.Success with persons
* - Uses stats.taggedPhotoCount (not photoCount)
* - Passes both personId AND faceModelId to methods
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonInventoryScreen(
modifier: Modifier = Modifier,
@@ -44,316 +43,171 @@ fun PersonInventoryScreen(
var personToDelete by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) }
var personToScan by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Trained People") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
actions = {
IconButton(onClick = { viewModel.loadPersons() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
)
}
) { paddingValues ->
Box(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (val state = uiState) {
is PersonInventoryViewModel.InventoryUiState.Loading -> {
LoadingView()
}
is PersonInventoryViewModel.InventoryUiState.Success -> {
if (state.persons.isEmpty()) {
EmptyView()
} else {
PersonListView(
persons = state.persons,
onDeleteClick = { personToDelete = it },
onScanClick = { personToScan = it },
onViewPhotos = { onViewPersonPhotos(it.person.id) },
scanningState = scanningState
)
}
}
is PersonInventoryViewModel.InventoryUiState.Error -> {
ErrorView(
message = state.message,
onRetry = { viewModel.loadPersons() }
)
}
}
// Scanning overlay
if (scanningState is PersonInventoryViewModel.ScanningState.Scanning) {
ScanningOverlay(scanningState as PersonInventoryViewModel.ScanningState.Scanning)
}
}
}
// Delete confirmation dialog
personToDelete?.let { personWithStats ->
AlertDialog(
onDismissRequest = { personToDelete = null },
title = { Text("Delete ${personWithStats.person.name}?") },
text = {
Text(
"This will delete the face model and all ${personWithStats.stats.taggedPhotoCount} " +
"face tags. Your photos will NOT be deleted."
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.deletePerson(
personWithStats.person.id,
personWithStats.stats.faceModelId
)
personToDelete = null
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { personToDelete = null }) {
Text("Cancel")
}
}
)
}
// Scan library confirmation dialog
personToScan?.let { personWithStats ->
AlertDialog(
onDismissRequest = { personToScan = null },
icon = { Icon(Icons.Default.Search, contentDescription = null) },
title = { Text("Scan Library for ${personWithStats.person.name}?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"This will scan your entire photo library and automatically tag " +
"all photos containing ${personWithStats.person.name}."
)
Text(
"Currently tagged: ${personWithStats.stats.taggedPhotoCount} photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
Button(
onClick = {
viewModel.scanLibraryForPerson(
personWithStats.person.id,
personWithStats.stats.faceModelId
)
personToScan = null
}
) {
Icon(Icons.Default.Search, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Scan")
}
},
dismissButton = {
TextButton(onClick = { personToScan = null }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun LoadingView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = "Loading trained models...",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
private fun EmptyView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
)
Text(
text = "No trained people yet",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Train a person using 10+ photos to start recognizing faces",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ErrorView(
message: String,
onRetry: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
}
}
@Composable
private fun PersonListView(
persons: List<PersonInventoryViewModel.PersonWithStats>,
onDeleteClick: (PersonInventoryViewModel.PersonWithStats) -> Unit,
onScanClick: (PersonInventoryViewModel.PersonWithStats) -> Unit,
onViewPhotos: (PersonInventoryViewModel.PersonWithStats) -> Unit,
scanningState: PersonInventoryViewModel.ScanningState
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Summary card
item {
SummaryCard(totalPersons = persons.size)
Spacer(modifier = Modifier.height(8.dp))
}
when (val state = uiState) {
is PersonInventoryViewModel.InventoryUiState.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
// Person cards
items(persons) { personWithStats ->
PersonCard(
personWithStats = personWithStats,
onDeleteClick = { onDeleteClick(personWithStats) },
onScanClick = { onScanClick(personWithStats) },
onViewPhotos = { onViewPhotos(personWithStats) },
isScanning = scanningState is PersonInventoryViewModel.ScanningState.Scanning &&
scanningState.personId == personWithStats.person.id
)
is PersonInventoryViewModel.InventoryUiState.Success -> {
// Summary card
item {
SummaryCard(
peopleCount = state.persons.size,
totalPhotos = state.persons.sumOf { it.stats.taggedPhotoCount }
)
}
// Scanning progress
val currentScanningState = scanningState
if (currentScanningState is PersonInventoryViewModel.ScanningState.Scanning) {
item {
ScanningProgressCard(currentScanningState)
}
}
// Person list
if (state.persons.isEmpty()) {
item {
EmptyState()
}
} else {
items(state.persons) { person ->
PersonCard(
person = person,
onDelete = { personToDelete = person },
onScan = { personToScan = person },
onViewPhotos = { onViewPersonPhotos(person.person.id) }
)
}
}
}
is PersonInventoryViewModel.InventoryUiState.Error -> {
item {
ErrorCard(message = state.message)
}
}
}
}
// Delete confirmation
personToDelete?.let { person ->
DeleteDialog(
person = person,
onDismiss = { personToDelete = null },
onConfirm = {
viewModel.deletePerson(person.person.id, person.stats.faceModelId)
personToDelete = null
}
)
}
// Scan confirmation
personToScan?.let { person ->
ScanDialog(
person = person,
onDismiss = { personToScan = null },
onConfirm = {
viewModel.scanLibraryForPerson(person.person.id, person.stats.faceModelId)
personToScan = null
}
)
}
}
/**
* Summary card with stats
*/
@Composable
private fun SummaryCard(totalPersons: Int) {
private fun SummaryCard(peopleCount: Int, totalPhotos: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceEvenly
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
StatItem(
icon = Icons.Default.People,
value = peopleCount.toString(),
label = "People"
)
VerticalDivider(
modifier = Modifier.height(56.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
StatItem(
icon = Icons.Default.PhotoLibrary,
value = totalPhotos.toString(),
label = "Tagged"
)
Column {
Text(
text = "$totalPersons trained ${if (totalPersons == 1) "person" else "people"}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Face recognition models ready",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
private fun PersonCard(
personWithStats: PersonInventoryViewModel.PersonWithStats,
onDeleteClick: () -> Unit,
onScanClick: () -> Unit,
onViewPhotos: () -> Unit,
isScanning: Boolean
) {
val stats = personWithStats.stats
private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, label: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Person card with stats and actions
*/
@Composable
private fun PersonCard(
person: PersonInventoryViewModel.PersonWithStats,
onDelete: () -> Unit,
onScan: () -> Unit,
onViewPhotos: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Header: Name and actions
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -363,38 +217,39 @@ private fun PersonCard(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
// Avatar
Surface(
modifier = Modifier.size(48.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = personWithStats.person.name.take(1).uppercase(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
)
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
// Name and stats
Column {
Text(
text = personWithStats.person.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
text = person.person.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "ID: ${personWithStats.person.id.take(8)}",
text = "${person.stats.taggedPhotoCount} photos • ${person.stats.trainingImageCount} trained",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = onDeleteClick) {
// Delete button
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
@@ -403,212 +258,251 @@ private fun PersonCard(
}
}
Spacer(modifier = Modifier.height(16.dp))
// Stats grid
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.PhotoCamera,
label = "Training",
value = "${stats.trainingImageCount}"
)
StatItem(
icon = Icons.Default.AccountBox,
label = "Tagged",
value = "${stats.taggedPhotoCount}"
)
StatItem(
icon = Icons.Default.CheckCircle,
label = "Confidence",
value = "${(stats.averageConfidence * 100).toInt()}%",
valueColor = if (stats.averageConfidence >= 0.8f) {
MaterialTheme.colorScheme.primary
} else if (stats.averageConfidence >= 0.6f) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.error
}
)
}
Spacer(modifier = Modifier.height(16.dp))
// Last detected
stats.lastDetectedAt?.let { timestamp ->
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.DateRange,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Last detected: ${formatDate(timestamp)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Action buttons row
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Scan Library button (PRIMARY ACTION)
Button(
onClick = onScanClick,
modifier = Modifier.weight(1f),
enabled = !isScanning,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
OutlinedButton(
onClick = onScan,
modifier = Modifier.weight(1f)
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(if (isScanning) "Scanning..." else "Scan Library")
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("Scan")
}
// View photos button
if (stats.taggedPhotoCount > 0) {
OutlinedButton(
onClick = onViewPhotos,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Photo,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("View (${stats.taggedPhotoCount})")
}
Button(
onClick = onViewPhotos,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("View")
}
}
}
}
}
@Composable
private fun StatItem(
icon: ImageVector,
label: String,
value: String,
valueColor: Color = MaterialTheme.colorScheme.primary
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = valueColor
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Scanning overlay showing progress
* Scanning progress card
*/
@Composable
private fun ScanningOverlay(state: PersonInventoryViewModel.ScanningState.Scanning) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
contentAlignment = Alignment.Center
private fun ScanningProgressCard(scanningState: PersonInventoryViewModel.ScanningState.Scanning) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)
)
) {
Card(
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Scanning Library",
style = MaterialTheme.typography.titleLarge,
"Scanning for ${scanningState.personName}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Finding ${state.personName} in your photos...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
LinearProgressIndicator(
progress = { state.progress / state.total.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "${state.progress} / ${state.total} photos scanned",
"${scanningState.progress}/${scanningState.total}",
style = MaterialTheme.typography.bodySmall
)
}
LinearProgressIndicator(
progress = {
if (scanningState.total > 0) {
scanningState.progress.toFloat() / scanningState.total.toFloat()
} else {
0f
}
},
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${state.facesFound} faces detected",
style = MaterialTheme.typography.labelMedium,
"Matches found: ${scanningState.facesFound}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
"Faces: ${scanningState.facesDetected}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
private fun formatDate(timestamp: Long): String {
val formatter = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault())
return formatter.format(Date(timestamp))
/**
* Empty state
*/
@Composable
private fun EmptyState() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.PersonOff,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Text(
"No People Trained",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"Train face recognition to find people in your photos",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
/**
* Error card
*/
@Composable
private fun ErrorCard(message: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
/**
* Delete confirmation dialog
*/
@Composable
private fun DeleteDialog(
person: PersonInventoryViewModel.PersonWithStats,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = { Text("Delete ${person.person.name}?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("This will permanently delete:")
Text("• Face recognition model", style = MaterialTheme.typography.bodyMedium)
Text("${person.stats.taggedPhotoCount} tagged photos will be untagged", style = MaterialTheme.typography.bodyMedium)
Text(
"This action cannot be undone.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
/**
* Scan confirmation dialog
*/
@Composable
private fun ScanDialog(
person: PersonInventoryViewModel.PersonWithStats,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Search, contentDescription = null) },
title = { Text("Scan for ${person.person.name}?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("This will:")
Text("• Scan all photos in your library", style = MaterialTheme.typography.bodyMedium)
Text("• Detect and tag ${person.person.name}'s face", style = MaterialTheme.typography.bodyMedium)
Text("• May take several minutes", style = MaterialTheme.typography.bodyMedium)
}
},
confirmButton = {
Button(onClick = onConfirm) {
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Start Scan")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -1,35 +1,34 @@
package com.placeholder.sherpai2.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
import coil.compose.AsyncImage
/**
* SearchScreen - COMPLETE REDESIGN
* ADVANCED SearchScreen with Boolean Logic
*
* Features:
* - Near-match search ("low" → "low_res")
* - Quick tag filter chips
* - Date range filtering
* - Clean person-only display
* - Simple/Verbose toggle
* - Include/Exclude people (visual chips)
* - Include/Exclude tags (visual chips)
* - Clear visual distinction (green = include, red = exclude)
* - Real-time filtering
* - OpenSearch-style query building
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -37,411 +36,507 @@ fun SearchScreen(
modifier: Modifier = Modifier,
searchViewModel: SearchViewModel,
onImageClick: (String) -> Unit,
onAlbumClick: (String) -> Unit = {} // For opening album view
onAlbumClick: ((String) -> Unit)? = null
) {
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val activeTagFilters by searchViewModel.activeTagFilters.collectAsStateWithLifecycle()
val includedPeople by searchViewModel.includedPeople.collectAsStateWithLifecycle()
val excludedPeople by searchViewModel.excludedPeople.collectAsStateWithLifecycle()
val includedTags by searchViewModel.includedTags.collectAsStateWithLifecycle()
val excludedTags by searchViewModel.excludedTags.collectAsStateWithLifecycle()
val dateRange by searchViewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by searchViewModel.displayMode.collectAsStateWithLifecycle()
val systemTags by searchViewModel.systemTags.collectAsStateWithLifecycle()
val availablePeople by searchViewModel.availablePeople.collectAsStateWithLifecycle()
val availableTags by searchViewModel.availableTags.collectAsStateWithLifecycle()
val images by searchViewModel
.searchImages()
.collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
var showPeoplePicker by remember { mutableStateOf(false) }
var showTagPicker by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxSize()) {
// Search bar + quick add buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 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),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Title
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column {
Text(
text = "Search Photos",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Near-match • Filters • Smart tags",
style = MaterialTheme.typography.bodySmall,
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
)
OutlinedTextField(
value = searchQuery,
onValueChange = { searchViewModel.setSearchQuery(it) },
placeholder = { Text("Search tags...") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchViewModel.setSearchQuery("") }) {
Icon(Icons.Default.Close, "Clear")
}
}
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchViewModel.setSearchQuery(it) },
placeholder = { Text("Search... (e.g., 'low', 'gro', 'nig')") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchViewModel.setSearchQuery("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(16.dp)
)
}
}
// Quick Tag Filters
if (systemTags.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
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
)
}
}
}
}
// Date Range Filters
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
},
modifier = Modifier.weight(1f),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
// Results
if (images.isEmpty() && searchQuery.isBlank() && activeTagFilters.isEmpty()) {
EmptySearchState()
} else if (images.isEmpty()) {
NoResultsState(
query = searchQuery,
hasFilters = activeTagFilters.isNotEmpty() || dateRange != DateRange.ALL_TIME
// Add person button
IconButton(
onClick = { showPeoplePicker = true },
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
} else {
Column {
// Results header
) {
Icon(Icons.Default.PersonAdd, "Add person filter")
}
// Add tag button
IconButton(
onClick = { showTagPicker = true },
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Icon(Icons.Default.LabelImportant, "Add tag filter")
}
}
// Active filters display (chips)
if (searchViewModel.hasActiveFilters()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${images.size} ${if (images.size == 1) "photo" else "photos"}",
style = MaterialTheme.typography.titleMedium,
"Active Filters",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
// 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
)
}
}
}
}
}
}
}
/**
* Photo card with clean person display
*/
@Composable
private fun PhotoCard(
imageWithFaceTags: ImageWithFaceTags,
displayMode: DisplayMode,
onImageClick: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
// Image
ImageGridItem(
image = imageWithFaceTags.image,
onClick = { onImageClick(imageWithFaceTags.image.imageUri) }
)
// Person tags (deduplicated)
val uniquePersons = imageWithFaceTags.persons.distinctBy { it.id }
if (uniquePersons.isNotEmpty()) {
when (displayMode) {
DisplayMode.SIMPLE -> {
// SIMPLE: Just names, no icons, no percentages
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth()
TextButton(
onClick = { searchViewModel.clearAllFilters() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = uniquePersons
.take(3)
.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text("Clear All", style = MaterialTheme.typography.labelMedium)
}
}
DisplayMode.VERBOSE -> {
// VERBOSE: Person tags + System tags
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Person tags with confidence
uniquePersons.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
)
// Find matching face tag for confidence
val matchingTag = imageWithFaceTags.faceTags
.find { tag ->
imageWithFaceTags.persons[imageWithFaceTags.faceTags.indexOf(tag)].id == person.id
}
if (matchingTag != null) {
val confidence = (matchingTag.confidence * 100).toInt()
Text(
text = "$confidence%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
if (uniquePersons.size > 3) {
Text(
text = "+${uniquePersons.size - 3} more",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
// Included People (GREEN)
if (includedPeople.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(includedPeople.toList()) { personId ->
val person = availablePeople.find { it.id == personId }
if (person != null) {
FilterChip(
selected = true,
onClick = { searchViewModel.excludePerson(personId) },
onLongClick = { searchViewModel.removePersonFilter(personId) },
label = { Text(person.name) },
leadingIcon = {
Icon(Icons.Default.Person, null, Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Check, null, Modifier.size(16.dp))
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF4CAF50), // Green
selectedLabelColor = Color.White
)
)
}
}
}
}
// System tags (verbose mode only)
// TODO: Get image tags from ImageWithEverything
// For now, show placeholder
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
// Example system tags - replace with actual tags from image
SystemTagChip("indoor")
SystemTagChip("high_res")
SystemTagChip("morning")
// Excluded People (RED)
if (excludedPeople.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(excludedPeople.toList()) { personId ->
val person = availablePeople.find { it.id == personId }
if (person != null) {
FilterChip(
selected = true,
onClick = { searchViewModel.includePerson(personId) },
onLongClick = { searchViewModel.removePersonFilter(personId) },
label = { Text(person.name) },
leadingIcon = {
Icon(Icons.Default.Person, null, Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Close, null, Modifier.size(16.dp))
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFFF44336), // Red
selectedLabelColor = Color.White
)
)
}
}
}
}
// Included Tags (GREEN)
if (includedTags.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(includedTags.toList()) { tagValue ->
FilterChip(
selected = true,
onClick = { searchViewModel.excludeTag(tagValue) },
onLongClick = { searchViewModel.removeTagFilter(tagValue) },
label = { Text(tagValue) },
leadingIcon = {
Icon(Icons.Default.Label, null, Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Check, null, Modifier.size(16.dp))
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF4CAF50),
selectedLabelColor = Color.White
)
)
}
}
}
// Excluded Tags (RED)
if (excludedTags.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(excludedTags.toList()) { tagValue ->
FilterChip(
selected = true,
onClick = { searchViewModel.includeTag(tagValue) },
onLongClick = { searchViewModel.removeTagFilter(tagValue) },
label = { Text(tagValue) },
leadingIcon = {
Icon(Icons.Default.Label, null, Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Close, null, Modifier.size(16.dp))
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFFF44336),
selectedLabelColor = Color.White
)
)
}
}
}
// Date range
if (dateRange != DateRange.ALL_TIME) {
FilterChip(
selected = true,
onClick = { searchViewModel.setDateRange(DateRange.ALL_TIME) },
label = { Text(dateRange.displayName) },
leadingIcon = {
Icon(Icons.Default.DateRange, null, Modifier.size(16.dp))
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.tertiaryContainer
)
)
}
}
}
}
// Results
if (images.isEmpty() && !searchViewModel.hasActiveFilters()) {
EmptyState()
} else if (images.isEmpty()) {
NoResultsState()
} else {
// Results count
Text(
text = "${images.size} photos • ${searchViewModel.getSearchSummary()}",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
// Image grid
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = images,
key = { it.image.imageUri }
) { imageWithTags ->
Card(
modifier = Modifier
.aspectRatio(1f)
.clickable { onImageClick(imageWithTags.image.imageUri) }
) {
AsyncImage(
model = imageWithTags.image.imageUri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
}
}
}
}
}
}
@Composable
private fun SystemTagChip(tagValue: String) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
) {
Text(
text = tagValue.replace("_", " "),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
// People picker dialog
if (showPeoplePicker) {
PeoplePickerDialog(
people = availablePeople,
includedPeople = includedPeople,
excludedPeople = excludedPeople,
onInclude = { searchViewModel.includePerson(it) },
onExclude = { searchViewModel.excludePerson(it) },
onDismiss = { showPeoplePicker = false }
)
}
// Tag picker dialog
if (showTagPicker) {
TagPickerDialog(
tags = availableTags,
includedTags = includedTags,
excludedTags = excludedTags,
onInclude = { searchViewModel.includeTag(it) },
onExclude = { searchViewModel.excludeTag(it) },
onDismiss = { showTagPicker = false }
)
}
}
@Composable
private fun EmptySearchState() {
private fun FilterChip(
selected: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
label: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: androidx.compose.material3.SelectableChipColors = FilterChipDefaults.filterChipColors()
) {
androidx.compose.material3.FilterChip(
selected = selected,
onClick = onClick,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
colors = colors
)
}
@Composable
private fun PeoplePickerDialog(
people: List<com.placeholder.sherpai2.data.local.entity.PersonEntity>,
includedPeople: Set<String>,
excludedPeople: Set<String>,
onInclude: (String) -> Unit,
onExclude: (String) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add People Filter") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Tap to INCLUDE (green) • Long press to EXCLUDE (red)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
people.forEach { person ->
val isIncluded = person.id in includedPeople
val isExcluded = person.id in excludedPeople
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onInclude(person.id) },
colors = CardDefaults.cardColors(
containerColor = when {
isIncluded -> Color(0xFF4CAF50).copy(alpha = 0.3f)
isExcluded -> Color(0xFFF44336).copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(person.name, fontWeight = FontWeight.Medium)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = { onInclude(person.id) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (isIncluded) Color(0xFF4CAF50) else Color.Transparent
)
) {
Icon(Icons.Default.Check, "Include", tint = if (isIncluded) Color.White else MaterialTheme.colorScheme.onSurface)
}
IconButton(
onClick = { onExclude(person.id) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (isExcluded) Color(0xFFF44336) else Color.Transparent
)
) {
Icon(Icons.Default.Close, "Exclude", tint = if (isExcluded) Color.White else MaterialTheme.colorScheme.onSurface)
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done")
}
}
)
}
@Composable
private fun TagPickerDialog(
tags: List<String>,
includedTags: Set<String>,
excludedTags: Set<String>,
onInclude: (String) -> Unit,
onExclude: (String) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Tag Filter") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Tap to INCLUDE (green) • Long press to EXCLUDE (red)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
tags.forEach { tagValue ->
val isIncluded = tagValue in includedTags
val isExcluded = tagValue in excludedTags
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onInclude(tagValue) },
colors = CardDefaults.cardColors(
containerColor = when {
isIncluded -> Color(0xFF4CAF50).copy(alpha = 0.3f)
isExcluded -> Color(0xFFF44336).copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(tagValue, fontWeight = FontWeight.Medium)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(
onClick = { onInclude(tagValue) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (isIncluded) Color(0xFF4CAF50) else Color.Transparent
)
) {
Icon(Icons.Default.Check, "Include", tint = if (isIncluded) Color.White else MaterialTheme.colorScheme.onSurface)
}
IconButton(
onClick = { onExclude(tagValue) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (isExcluded) Color(0xFFF44336) else Color.Transparent
)
) {
Icon(Icons.Default.Close, "Exclude", tint = if (isExcluded) Color.White else MaterialTheme.colorScheme.onSurface)
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done")
}
}
)
}
@Composable
private fun EmptyState() {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Text(
text = "Search or filter photos",
"Advanced Search",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Try searching or tapping quick filters",
"Add people and tags to build your search",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -450,67 +545,33 @@ private fun EmptySearchState() {
}
@Composable
private fun NoResultsState(query: String, hasFilters: Boolean) {
private fun NoResultsState() {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Default.SearchOff,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Text(
text = "No results found",
"No photos found",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (query.isNotBlank()) {
Text(
text = "No matches for \"$query\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (hasFilters) {
Text(
text = "Try removing some filters",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"Try different filters",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/**
* Get emoji for tag type
*/
private fun getTagEmoji(tagValue: String): String {
return when (tagValue) {
"night" -> "🌙"
"morning" -> "🌅"
"afternoon" -> "☀️"
"evening" -> "🌇"
"indoor" -> "🏠"
"outdoor" -> "🌲"
"group_photo" -> "👥"
"selfie" -> "🤳"
"couple" -> "💑"
"family" -> "👨‍👩‍👧"
"friend" -> "🤝"
"birthday" -> "🎂"
"high_res" -> ""
"low_res" -> "📦"
"landscape" -> "🖼️"
"portrait" -> "📱"
"square" -> ""
else -> "🏷️"
}
}

View File

@@ -2,13 +2,13 @@ package com.placeholder.sherpai2.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
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.local.entity.TagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.domain.repository.ImageRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@@ -16,219 +16,240 @@ import java.util.Calendar
import javax.inject.Inject
/**
* SearchViewModel - COMPLETE REDESIGN
* OPTIMIZED SearchViewModel with Boolean Logic
*
* Features:
* - Near-match search ("low" → "low_res", "gro" → "group_photo")
* - Date range filtering
* - Quick tag filters
* - Clean person-only display
* - Simple/Verbose toggle
* PERFORMANCE: NO N+1 QUERIES!
* ✅ ImageAggregateDao loads tags via @Relation (1 query for 100 images!)
* ✅ Person cache for O(1) faceModelId lookups
* ✅ All filtering in memory (FAST)
*/
@HiltViewModel
class SearchViewModel @Inject constructor(
private val imageRepository: ImageRepository,
private val imageAggregateDao: ImageAggregateDao,
private val faceRecognitionRepository: FaceRecognitionRepository,
private val personDao: PersonDao,
private val tagDao: TagDao
) : 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()
private val _includedPeople = MutableStateFlow<Set<String>>(emptySet())
val includedPeople: StateFlow<Set<String>> = _includedPeople.asStateFlow()
private val _excludedPeople = MutableStateFlow<Set<String>>(emptySet())
val excludedPeople: StateFlow<Set<String>> = _excludedPeople.asStateFlow()
private val _includedTags = MutableStateFlow<Set<String>>(emptySet())
val includedTags: StateFlow<Set<String>> = _includedTags.asStateFlow()
private val _excludedTags = MutableStateFlow<Set<String>>(emptySet())
val excludedTags: StateFlow<Set<String>> = _excludedTags.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()
private val _availablePeople = MutableStateFlow<List<PersonEntity>>(emptyList())
val availablePeople: StateFlow<List<PersonEntity>> = _availablePeople.asStateFlow()
// Available system tags for quick filters
private val _systemTags = MutableStateFlow<List<TagEntity>>(emptyList())
val systemTags: StateFlow<List<TagEntity>> = _systemTags.asStateFlow()
private val _availableTags = MutableStateFlow<List<String>>(emptyList())
val availableTags: StateFlow<List<String>> = _availableTags.asStateFlow()
private val personCache = mutableMapOf<String, String>()
init {
loadSystemTags()
loadAvailableFilters()
buildPersonCache()
}
private fun buildPersonCache() {
viewModelScope.launch {
val people = personDao.getAllPersons()
people.forEach { person ->
val stats = faceRecognitionRepository.getPersonFaceStats(person.id)
if (stats != null) {
personCache[stats.faceModelId] = person.id
}
}
}
}
/**
* Main search flow - combines query, tag filters, and date range
*/
fun searchImages(): Flow<List<ImageWithFaceTags>> {
return combine(
_searchQuery,
_activeTagFilters,
_includedPeople,
_excludedPeople,
_includedTags,
_excludedTags,
_dateRange
) { query, tagFilters, dateRange ->
Triple(query, tagFilters, dateRange)
}.flatMapLatest { (query, tagFilters, dateRange) ->
channelFlow {
// Get matching tags FIRST (suspend call)
val matchingTags = if (query.isNotBlank()) {
findMatchingTags(query)
} else {
emptyList()
}
// Get base images
val imagesFlow = when {
matchingTags.isNotEmpty() -> {
// Search by all matching tags
combine(matchingTags.map { tag ->
imageRepository.findImagesByTag(tag.value)
}) { results ->
results.flatMap { it }.distinctBy { it.image.imageId }
) { values: Array<*> ->
SearchCriteria(
query = values[0] as String,
includedPeople = values[1] as Set<String>,
excludedPeople = values[2] as Set<String>,
includedTags = values[3] as Set<String>,
excludedTags = values[4] as Set<String>,
dateRange = values[5] as DateRange
)
}.flatMapLatest { criteria ->
imageAggregateDao.observeAllImagesWithEverything()
.map { imagesList ->
imagesList.mapNotNull { imageWithEverything ->
if (!isInDateRange(imageWithEverything.image.capturedAt, criteria.dateRange)) {
return@mapNotNull null
}
}
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
)
val personIds = imageWithEverything.faceTags
.mapNotNull { faceTag -> personCache[faceTag.faceModelId] }
.toSet()
val imageTags = imageWithEverything.tags
.map { it.value }
.toSet()
val passesFilter = applyBooleanLogic(
personIds = personIds,
imageTags = imageTags,
criteria = criteria
)
if (passesFilter) {
val persons = personIds.mapNotNull { personId ->
_availablePeople.value.find { it.id == personId }
}
ImageWithFaceTags(
image = imageWithEverything.image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
faceTags = imageWithEverything.faceTags,
persons = persons
)
} else {
null
}
.sortedByDescending { it.image.capturedAt }
send(filtered)
}.sortedByDescending { it.image.capturedAt }
}
}
}
}
/**
* Near-match search: "low" matches "low_res", "gro" matches "group_photo"
*/
private suspend fun findMatchingTags(query: String): List<TagEntity> {
val normalizedQuery = query.trim().lowercase()
private fun applyBooleanLogic(
personIds: Set<String>,
imageTags: Set<String>,
criteria: SearchCriteria
): Boolean {
val hasAllIncludedPeople = if (criteria.includedPeople.isNotEmpty()) {
criteria.includedPeople.all { it in personIds }
} else true
// Get all system tags
val allTags = tagDao.getByType("SYSTEM")
val hasNoExcludedPeople = if (criteria.excludedPeople.isNotEmpty()) {
criteria.excludedPeople.none { it in personIds }
} else true
// Find tags that contain the query or match it closely
return allTags.filter { tag ->
val tagValue = tag.value.lowercase()
val hasAllIncludedTags = if (criteria.includedTags.isNotEmpty()) {
criteria.includedTags.all { it in imageTags }
} else true
// 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
}
}
val hasNoExcludedTags = if (criteria.excludedTags.isNotEmpty()) {
criteria.excludedTags.none { it in imageTags }
} else true
val matchesTextSearch = if (criteria.query.isNotBlank()) {
val normalizedQuery = criteria.query.trim().lowercase()
imageTags.any { tag -> tag.lowercase().contains(normalizedQuery) }
} else true
return hasAllIncludedPeople && hasNoExcludedPeople &&
hasAllIncludedTags && hasNoExcludedTags &&
matchesTextSearch
}
/**
* Load available system tags for quick filters
*/
private fun loadSystemTags() {
private fun loadAvailableFilters() {
viewModelScope.launch {
val tags = tagDao.getByType("SYSTEM")
val people = personDao.getAllPersons()
_availablePeople.value = people.sortedBy { it.name }
// Get usage counts for all tags
val tags = tagDao.getByType("SYSTEM")
val tagsWithUsage = tags.map { tag ->
tag to tagDao.getTagUsageCount(tag.tagId)
}
// Sort by most commonly used
val sortedTags = tagsWithUsage
_availableTags.value = tagsWithUsage
.sortedByDescending { (_, usageCount) -> usageCount }
.take(12) // Show top 12 most used tags
.map { (tag, _) -> tag }
_systemTags.value = sortedTags
.take(30)
.map { (tag, _) -> tag.value }
}
}
/**
* Update search query
*/
fun includePerson(personId: String) {
_includedPeople.value = _includedPeople.value + personId
_excludedPeople.value = _excludedPeople.value - personId
}
fun excludePerson(personId: String) {
_excludedPeople.value = _excludedPeople.value + personId
_includedPeople.value = _includedPeople.value - personId
}
fun removePersonFilter(personId: String) {
_includedPeople.value = _includedPeople.value - personId
_excludedPeople.value = _excludedPeople.value - personId
}
fun includeTag(tagValue: String) {
_includedTags.value = _includedTags.value + tagValue
_excludedTags.value = _excludedTags.value - tagValue
}
fun excludeTag(tagValue: String) {
_excludedTags.value = _excludedTags.value + tagValue
_includedTags.value = _includedTags.value - tagValue
}
fun removeTagFilter(tagValue: String) {
_includedTags.value = _includedTags.value - tagValue
_excludedTags.value = _excludedTags.value - tagValue
}
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
}
fun clearAllFilters() {
_searchQuery.value = ""
_includedPeople.value = emptySet()
_excludedPeople.value = emptySet()
_includedTags.value = emptySet()
_excludedTags.value = emptySet()
_dateRange.value = DateRange.ALL_TIME
}
/**
* 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)
}
fun hasActiveFilters(): Boolean {
return _searchQuery.value.isNotBlank() ||
_includedPeople.value.isNotEmpty() ||
_excludedPeople.value.isNotEmpty() ||
_includedTags.value.isNotEmpty() ||
_excludedTags.value.isNotEmpty() ||
_dateRange.value != DateRange.ALL_TIME
}
fun getSearchSummary(): String {
val parts = mutableListOf<String>()
if (_includedPeople.value.isNotEmpty()) parts.add("WITH: ${_includedPeople.value.size} people")
if (_excludedPeople.value.isNotEmpty()) parts.add("WITHOUT: ${_excludedPeople.value.size} people")
if (_includedTags.value.isNotEmpty()) parts.add("HAS: ${_includedTags.value.size} tags")
if (_excludedTags.value.isNotEmpty()) parts.add("NOT: ${_excludedTags.value.size} tags")
if (_dateRange.value != DateRange.ALL_TIME) parts.add(_dateRange.value.displayName)
return parts.joinToString("")
}
private fun isInDateRange(timestamp: Long, range: DateRange): Boolean = 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 {
@@ -259,18 +280,21 @@ class SearchViewModel @Inject constructor(
}
}
/**
* Data class containing image with face recognition data
*/
private data class SearchCriteria(
val query: String,
val includedPeople: Set<String>,
val excludedPeople: Set<String>,
val includedTags: Set<String>,
val excludedTags: Set<String>,
val dateRange: DateRange
)
data class ImageWithFaceTags(
val image: ImageEntity,
val faceTags: List<PhotoFaceTagEntity>,
val persons: List<PersonEntity>
)
/**
* Date range filters
*/
enum class DateRange(val displayName: String) {
ALL_TIME("All Time"),
TODAY("Today"),
@@ -279,10 +303,5 @@ enum class DateRange(val displayName: String) {
THIS_YEAR("This Year")
}
/**
* Display modes for photo tags
*/
enum class DisplayMode {
SIMPLE, // Just person names
VERBOSE // Names + icons + confidence percentages
}
@Deprecated("No longer used")
enum class DisplayMode { SIMPLE, VERBOSE }

View File

@@ -24,9 +24,24 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
/**
* CLEANED TagManagementScreen - No Scaffold wrapper
*
* Removed:
* - Scaffold wrapper (line 38)
* - Moved FAB inline as part of content
*
* Features:
* - Tag list with usage counts
* - Search functionality
* - Scanning progress
* - Delete tags
* - System/User tag distinction
*/
@Composable
fun TagManagementScreen(
viewModel: TagManagementViewModel = hiltViewModel()
viewModel: TagManagementViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsState()
val scanningState by viewModel.scanningState.collectAsState()
@@ -35,105 +50,8 @@ fun TagManagementScreen(
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)
) {
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Stats Bar
StatsBar(uiState)
@@ -166,24 +84,60 @@ fun TagManagementScreen(
}
}
is TagManagementViewModel.TagUiState.Success -> {
TagList(
tags = state.tags,
onDeleteTag = { viewModel.deleteTag(it) }
)
if (state.tags.isEmpty()) {
EmptyTagsView()
} else {
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
)
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
)
}
}
}
}
}
// FAB (inline, positioned over content)
ScanFAB(
showMenu = showScanMenu,
onToggleMenu = { showScanMenu = !showScanMenu },
onScanAll = {
viewModel.scanForAllTags()
showScanMenu = false
},
onScanBase = {
viewModel.scanForBaseTags()
showScanMenu = false
},
onScanRelationships = {
viewModel.scanForRelationshipTags()
showScanMenu = false
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
)
}
// Add Tag Dialog
@@ -196,73 +150,77 @@ fun TagManagementScreen(
}
)
}
// 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
}
)
}
}
/**
* Stats bar at top
*/
@Composable
private fun StatsBar(uiState: TagManagementViewModel.TagUiState) {
if (uiState is TagManagementViewModel.TagUiState.Success) {
Card(
val (totalTags, totalPhotos) = when (uiState) {
is TagManagementViewModel.TagUiState.Success -> {
val photoCount: Int = uiState.tags.sumOf { it.usageCount }
uiState.tags.size to photoCount
}
else -> 0 to 0
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
horizontalArrangement = Arrangement.SpaceEvenly
) {
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)
}
StatItem(
icon = Icons.Default.Label,
value = totalTags.toString(),
label = "Tags"
)
VerticalDivider(
modifier = Modifier.height(48.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
StatItem(
icon = Icons.Default.Photo,
value = totalPhotos.toString(),
label = "Tagged Photos"
)
}
}
}
@Composable
private fun StatItem(label: String, value: String, icon: ImageVector) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
private fun StatItem(icon: ImageVector, value: String, label: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = icon,
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Search bar
*/
@Composable
private fun SearchBar(
searchQuery: String,
@@ -273,9 +231,9 @@ private fun SearchBar(
onValueChange = onSearchChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.padding(16.dp),
placeholder = { Text("Search tags...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchChange("") }) {
@@ -283,96 +241,124 @@ private fun SearchBar(
}
}
},
singleLine = true
singleLine = true,
shape = RoundedCornerShape(16.dp)
)
}
/**
* Scanning progress indicator
*/
@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 -> {
when (scanningState) {
is TagManagementViewModel.TagScanningState.Scanning -> {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Error: ${scanningState.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
"Scanning: ${scanningState.scanType}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
Text(
"${scanningState.progress}/${scanningState.total}",
style = MaterialTheme.typography.bodySmall
)
}
LinearProgressIndicator(
progress = {
if (scanningState.total > 0) {
scanningState.progress.toFloat() / scanningState.total.toFloat()
} else {
0f
}
},
modifier = Modifier.fillMaxWidth()
)
Text(
"Tags applied: ${scanningState.tagsApplied}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
if (scanningState.currentImage.isNotEmpty()) {
Text(
"Current: ${scanningState.currentImage}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
is TagManagementViewModel.TagScanningState.Complete -> {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
"Scan Complete!",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
"Processed: ${scanningState.imagesProcessed} images",
style = MaterialTheme.typography.bodySmall
)
Text(
"Applied: ${scanningState.tagsApplied} tags",
style = MaterialTheme.typography.bodySmall
)
if (scanningState.newTagsCreated > 0) {
Text(
"Created: ${scanningState.newTagsCreated} new tags",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
else -> { /* Idle - don't show */ }
}
}
else -> {}
}
}
/**
* Tag list
*/
@Composable
private fun TagList(
tags: List<TagWithUsage>,
@@ -383,114 +369,238 @@ private fun TagList(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tags, key = { it.tagId }) { tag ->
TagListItem(tag, onDeleteTag)
items(tags) { tagWithUsage ->
TagCard(
tagWithUsage = tagWithUsage,
onDelete = { onDeleteTag(tagWithUsage.tagId) }
)
}
}
}
/**
* Individual tag card
*/
@Composable
private fun TagListItem(
tag: TagWithUsage,
onDeleteTag: (String) -> Unit
private fun TagCard(
tagWithUsage: TagWithUsage,
onDelete: () -> Unit
) {
var showDeleteConfirm by remember { mutableStateOf(false) }
val isSystemTag = tagWithUsage.type == "SYSTEM"
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { /* TODO: Navigate to images with this tag */ }
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
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
// Tag icon
Surface(
modifier = Modifier.size(40.dp),
shape = RoundedCornerShape(8.dp),
color = if (isSystemTag)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.primary
)
MaterialTheme.colorScheme.secondaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Icon(
if (isSystemTag) Icons.Default.AutoAwesome else Icons.Default.Label,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (isSystemTag)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
// Tag info
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = tagWithUsage.value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
if (isSystemTag) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Text(
"SYSTEM",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
Text(
text = tag.value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = if (tag.type == "SYSTEM") "System tag" else "User tag",
text = "${tagWithUsage.usageCount} ${if (tagWithUsage.usageCount == 1) "photo" else "photos"}",
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 (!isSystemTag) {
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
// 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")
}
/**
* Empty state
*/
@Composable
private fun EmptyTagsView() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.LabelOff,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Text(
"No Tags Yet",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"Scan your photos to generate tags automatically",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
/**
* Floating Action Button with scan menu
*/
@Composable
private fun ScanFAB(
showMenu: Boolean,
onToggleMenu: () -> Unit,
onScanAll: () -> Unit,
onScanBase: () -> Unit,
onScanRelationships: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Menu options
AnimatedVisibility(visible = showMenu) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SmallFAB(
icon = Icons.Default.AutoFixHigh,
text = "Scan All",
onClick = onScanAll
)
SmallFAB(
icon = Icons.Default.PhotoCamera,
text = "Base Tags",
onClick = onScanBase
)
SmallFAB(
icon = Icons.Default.People,
text = "Relationships",
onClick = onScanRelationships
)
}
}
// Main FAB
ExtendedFloatingActionButton(
onClick = onToggleMenu,
icon = {
Icon(
if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh,
"Scan"
)
},
text = { Text(if (showMenu) "Close" else "Scan Tags") },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@Composable
private fun SmallFAB(
icon: ImageVector,
text: String,
onClick: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp
) {
Text(
text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
FloatingActionButton(
onClick = onClick,
modifier = Modifier.size(48.dp),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Icon(icon, contentDescription = text, modifier = Modifier.size(20.dp))
}
}
}
/**
* Add tag dialog
*/
@Composable
private fun AddTagDialog(
onDismiss: () -> Unit,
@@ -500,18 +610,19 @@ private fun AddTagDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Tag") },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
title = { Text("Add Custom Tag") },
text = {
OutlinedTextField(
value = tagName,
onValueChange = { tagName = it },
label = { Text("Tag name") },
label = { Text("Tag Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
Button(
onClick = { onConfirm(tagName) },
enabled = tagName.isNotBlank()
) {
@@ -524,101 +635,4 @@ private fun AddTagDialog(
}
}
)
}
@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

@@ -20,25 +20,31 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import androidx.compose.ui.graphics.Color
import com.google.mlkit.vision.face.FaceDetectorOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import java.io.InputStream
/**
* Dialog for selecting a face from multiple detected faces
* MINIMAL FacePickerDialog - Optimized for batch processing 30-50 photos
*
* CRITICAL: Re-detects faces on full resolution bitmap to ensure accurate cropping.
* Face bounds from FaceDetectionHelper are from downsampled images and won't match
* the full resolution bitmap loaded here.
* REMOVED CLUTTER:
* - "Preview (tap to select)" header
* - "Face will be used for training" info box
* - "Face #" labels covering previews
* - Original image preview
*
* IMPROVED:
* - Larger face previews (1:1 aspect ratio)
* - Clean checkmark overlay only
* - Minimal text
* - Fast workflow
*/
@Composable
fun FacePickerDialog(
@@ -107,9 +113,9 @@ fun FacePickerDialog(
) {
Card(
modifier = Modifier
.fillMaxWidth(0.94f)
.fillMaxWidth(0.92f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
@@ -117,175 +123,70 @@ fun FacePickerDialog(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
// Minimal header - just close button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Pick a Face",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "${result.faceCount} faces detected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = "${result.faceCount} faces",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.size(24.dp)
)
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
// Instruction
Text(
text = "Tap a face below to select it for training:",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (isLoading) {
// Loading state
// Loading state - minimal
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
.height(180.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
"Processing faces...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
CircularProgressIndicator()
}
} else if (errorMessage != null) {
// Error state
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
text = errorMessage ?: "Unknown error",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
} else {
// Original image preview
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(16.dp)
) {
AsyncImage(
model = result.uri,
contentDescription = "Original image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
// Face previews section
// Error state - minimal
Text(
text = "Preview (tap to select):",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
// Face preview cards
} else {
// CLEAN face grid - NO labels, NO text
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
croppedFaces.forEachIndexed { index, faceBitmap ->
FacePreviewCard(
CleanFaceCard(
faceBitmap = faceBitmap,
index = index + 1,
isSelected = selectedFaceIndex == index,
onClick = { selectedFaceIndex = index },
modifier = Modifier.weight(1f)
)
}
}
// Helper text
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"The selected face will be used for training",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Action buttons
// Action buttons - minimal
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
TextButton(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(52.dp),
shape = RoundedCornerShape(14.dp)
modifier = Modifier.weight(1f)
) {
Text("Cancel", style = MaterialTheme.typography.titleMedium)
Text("Skip")
}
Button(
@@ -294,19 +195,16 @@ fun FacePickerDialog(
onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex])
}
},
modifier = Modifier
.weight(1f)
.height(52.dp),
enabled = !isLoading && croppedFaces.isNotEmpty() && errorMessage == null,
shape = RoundedCornerShape(14.dp)
enabled = !isLoading && croppedFaces.isNotEmpty(),
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.CheckCircle,
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Use This Face", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.width(6.dp))
Text("Use")
}
}
}
@@ -315,102 +213,67 @@ fun FacePickerDialog(
}
/**
* Individual face preview card
* ULTRA-CLEAN face card - NO TEXT, just image + checkmark
*
* CHANGES:
* - 1:1 aspect ratio (bigger!)
* - NO "Face #" label
* - Checkmark in corner only
* - Minimal border
*/
@Composable
private fun FacePreviewCard(
private fun CleanFaceCard(
faceBitmap: Bitmap,
index: Int,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.aspectRatio(0.75f)
.aspectRatio(1f) // SQUARE = bigger previews!
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
border = if (isSelected)
BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
else
BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
shape = RoundedCornerShape(16.dp),
BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(
defaultElevation = if (isSelected) 8.dp else 2.dp
defaultElevation = if (isSelected) 4.dp else 1.dp
)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Face image
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Image(
bitmap = faceBitmap.asImageBitmap(),
contentDescription = "Face $index",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier.fillMaxSize()) {
// Face image - FULL SIZE
Image(
bitmap = faceBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Selected overlay with checkmark
if (isSelected) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Selected",
modifier = Modifier
.padding(12.dp)
.size(40.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
// Checkmark in corner - ONLY if selected
if (isSelected) {
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(32.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Selected",
modifier = Modifier
.padding(6.dp)
.size(20.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
// Face number label
Surface(
modifier = Modifier.fillMaxWidth(),
color = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)
) {
Text(
text = "Face $index",
modifier = Modifier.padding(vertical = 12.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@@ -423,7 +286,7 @@ private suspend fun loadFullResolutionBitmap(
uri: Uri
): Bitmap? = withContext(Dispatchers.IO) {
try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val inputStream = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close()
}
@@ -437,19 +300,18 @@ private suspend fun loadFullResolutionBitmap(
*/
private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List<Rect> = withContext(Dispatchers.Default) {
try {
val faceDetectorOptions = FaceDetectorOptions.Builder()
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.15f)
.setMinFaceSize(0.10f)
.build()
val detector = FaceDetection.getClient(faceDetectorOptions)
val inputImage = InputImage.fromBitmap(bitmap, 0)
val detector = FaceDetection.getClient(options)
val image = InputImage.fromBitmap(bitmap, 0)
val faces = detector.process(image).await()
val faces = detector.process(inputImage).await()
// Sort by face size (largest first)
// Sort by size (largest first)
faces.sortedByDescending { face ->
face.boundingBox.width() * face.boundingBox.height()
}.map { it.boundingBox }
@@ -460,7 +322,7 @@ private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List<Rect> = withContex
}
/**
* Helper function to crop face from bitmap with padding
* Crop face from bitmap with padding
*/
private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
// Add 20% padding around the face

View File

@@ -1,386 +0,0 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* IMPROVED FacePickerDialog
*
* Improvements:
* - Cleaner layout with better spacing
* - Larger face previews
* - Better visual feedback
* - More professional design
*/
@Composable
fun ImprovedFacePickerDialog(
result: FaceDetectionHelper.FaceDetectionResult,
onDismiss: () -> Unit,
onFaceSelected: (Int, Bitmap) -> Unit
) {
val context = LocalContext.current
var selectedFaceIndex by remember { mutableStateOf(0) } // Auto-select first face
var croppedFaces by remember { mutableStateOf<List<Bitmap>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
// Load and crop all faces
LaunchedEffect(result) {
isLoading = true
croppedFaces = withContext(Dispatchers.IO) {
val bitmap = loadBitmapFromUri(context, result.uri)
bitmap?.let { bmp ->
result.faceBounds.map { bounds ->
cropFaceFromBitmap(bmp, bounds)
}
} ?: emptyList()
}
isLoading = false
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth(0.94f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Pick a Face",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "${result.faceCount} faces detected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.size(24.dp)
)
}
}
// Instruction
Text(
text = "Tap a face below to select it for training:",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (isLoading) {
// Loading state
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
"Processing faces...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
// Original image preview (smaller)
Card(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(16.dp)
) {
AsyncImage(
model = result.uri,
contentDescription = "Original image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
// Face previews section
Text(
text = "Preview (tap to select):",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Face preview cards - horizontal scroll if more than 2
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
croppedFaces.forEachIndexed { index, faceBitmap ->
ImprovedFacePreviewCard(
faceBitmap = faceBitmap,
index = index + 1,
isSelected = selectedFaceIndex == index,
onClick = { selectedFaceIndex = index },
modifier = Modifier.weight(1f)
)
}
}
// Helper text
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"The selected face will be used for training",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.height(52.dp),
shape = RoundedCornerShape(14.dp)
) {
Text("Cancel", style = MaterialTheme.typography.titleMedium)
}
Button(
onClick = {
if (selectedFaceIndex < croppedFaces.size) {
onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex])
}
},
modifier = Modifier
.weight(1f)
.height(52.dp),
enabled = !isLoading && croppedFaces.isNotEmpty(),
shape = RoundedCornerShape(14.dp)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Use This Face", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
/**
* Improved face preview card with better visual design
*/
@Composable
private fun ImprovedFacePreviewCard(
faceBitmap: Bitmap,
index: Int,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.aspectRatio(0.75f) // Portrait aspect ratio for faces
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
),
border = if (isSelected)
BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
else
BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(
defaultElevation = if (isSelected) 8.dp else 2.dp
)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Face image
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Image(
bitmap = faceBitmap.asImageBitmap(),
contentDescription = "Face $index",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Selected overlay with checkmark
if (isSelected) {
Surface(
modifier = Modifier
.fillMaxSize(),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Selected",
modifier = Modifier
.padding(12.dp)
.size(40.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}
// Face number label
Surface(
modifier = Modifier.fillMaxWidth(),
color = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)
) {
Text(
text = "Face $index",
modifier = Modifier.padding(vertical = 12.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurface
)
}
}
}
}
/**
* Helper function to load bitmap from URI
*/
private suspend fun loadBitmapFromUri(
context: android.content.Context,
uri: Uri
): Bitmap? = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close()
}
} catch (e: Exception) {
null
}
}
/**
* Helper function to crop face from bitmap
*/
private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
// Add 20% padding around the face
val padding = (faceBounds.width() * 0.2f).toInt()
val left = (faceBounds.left - padding).coerceAtLeast(0)
val top = (faceBounds.top - padding).coerceAtLeast(0)
val right = (faceBounds.right + padding).coerceAtMost(bitmap.width)
val bottom = (faceBounds.bottom + padding).coerceAtMost(bitmap.height)
val width = right - left
val height = bottom - top
return Bitmap.createBitmap(bitmap, left, top, width, height)
}

View File

@@ -128,7 +128,7 @@ fun ScanResultsScreen(
// Face Picker Dialog
showFacePickerDialog?.let { result ->
ImprovedFacePickerDialog( // CHANGED
FacePickerDialog ( // CHANGED
result = result,
onDismiss = { showFacePickerDialog = null },
onFaceSelected = { faceIndex, croppedFaceBitmap ->

View File

@@ -13,97 +13,84 @@ 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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import java.text.SimpleDateFormat
import java.util.*
import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog
/**
* Beautiful TrainingScreen with person info capture
* CLEANED TrainingScreen - No duplicate header
*
* Removed:
* - Scaffold wrapper (lines 46-55)
* - TopAppBar (was creating banner)
* - "Train New Person" title (MainScreen shows it)
*
* Features:
* - Name input
* - Date of birth picker
* - Relationship selector
* - Person info capture (name, DOB, relationship)
* - Onboarding cards
* - Beautiful gradient design
* - Clear call to action
* - Scrollable on small screens
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrainingScreen(
onSelectImages: () -> Unit,
modifier: Modifier = Modifier,
trainViewModel: TrainViewModel = hiltViewModel()
) {
var showInfoDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Train New Person") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Hero section with gradient
HeroCard()
// How it works section
HowItWorksSection()
// Requirements section
RequirementsCard()
Spacer(Modifier.weight(1f))
// Main CTA button
Button(
onClick = { showInfoDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(16.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(12.dp))
Text(
"Start Training",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Hero section with gradient
HeroCard()
// How it works section
HowItWorksSection()
// Requirements section
RequirementsCard()
Spacer(Modifier.weight(1f))
// Main CTA button
Button(
onClick = { showInfoDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(16.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(12.dp))
Text(
"Start Training",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(8.dp))
}
Spacer(Modifier.height(8.dp))
}
// Person info dialog
if (showInfoDialog) {
BeautifulPersonInfoDialog( // CHANGED
BeautifulPersonInfoDialog(
onDismiss = { showInfoDialog = false },
onConfirm = { name, dob, relationship ->
showInfoDialog = false
@@ -200,16 +187,16 @@ private fun HowItWorksSection() {
StepCard(
number = 3,
icon = Icons.Default.ModelTraining,
title = "AI Learns Their Face",
description = "Takes ~30 seconds to train"
icon = Icons.Default.SmartToy,
title = "AI Training",
description = "We'll create a recognition model"
)
StepCard(
number = 4,
icon = Icons.Default.Search,
title = "Auto-Tag Your Library",
description = "Find them in all your photos"
icon = Icons.Default.AutoFixHigh,
title = "Auto-Tag Photos",
description = "Find this person across your library"
)
}
}
@@ -217,31 +204,31 @@ private fun HowItWorksSection() {
@Composable
private fun StepCard(
number: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector,
icon: ImageVector,
title: String,
description: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Number badge
// Number circle
Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
color = MaterialTheme.colorScheme.primary
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = number.toString(),
"$number",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
@@ -249,6 +236,7 @@ private fun StepCard(
}
}
// Content
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -266,7 +254,6 @@ private fun StepCard(
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(4.dp))
Text(
description,
style = MaterialTheme.typography.bodyMedium,
@@ -282,7 +269,7 @@ private fun RequirementsCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) {
@@ -297,225 +284,59 @@ private fun RequirementsCard() {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Text(
"What You'll Need",
"Best Results",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
RequirementItem("20-30 photos of the person", true)
RequirementItem("Different angles and lighting", true)
RequirementItem("Clear face visibility", true)
RequirementItem("Mix of expressions", true)
RequirementItem("2-3 minutes of your time", true)
}
}
}
RequirementItem(
icon = Icons.Default.PhotoCamera,
text = "20-30 photos minimum"
)
@Composable
private fun RequirementItem(text: String, isMet: Boolean) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (isMet) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isMet) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
RequirementItem(
icon = Icons.Default.Face,
text = "Clear, well-lit face photos"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PersonInfoDialog(
onDismiss: () -> Unit,
onConfirm: (name: String, dateOfBirth: Long?, relationship: String) -> Unit
) {
var name by remember { mutableStateOf("") }
var dateOfBirth by remember { mutableStateOf<Long?>(null) }
var selectedRelationship by remember { mutableStateOf("Other") }
var showDatePicker by remember { mutableStateOf(false) }
RequirementItem(
icon = Icons.Default.Diversity1,
text = "Variety of angles & expressions"
)
val relationships = listOf(
"Family" to "👨‍👩‍👧‍👦",
"Friend" to "🤝",
"Partner" to "❤️",
"Child" to "👶",
"Parent" to "👪",
"Sibling" to "👫",
"Colleague" to "💼",
"Other" to "👤"
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text("Person Details")
Text(
"Help us organize your photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name *") },
placeholder = { Text("e.g., John Doe") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Date of birth
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Cake, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
if (dateOfBirth != null) {
"Birthday: ${formatDate(dateOfBirth!!)}"
} else {
"Add Birthday (Optional)"
}
)
}
// Relationship selector
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Relationship",
style = MaterialTheme.typography.labelMedium
)
// Relationship chips
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
relationships.take(4).forEach { (rel, emoji) ->
FilterChip(
selected = selectedRelationship == rel,
onClick = { selectedRelationship = rel },
label = { Text("$emoji $rel") }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
relationships.drop(4).forEach { (rel, emoji) ->
FilterChip(
selected = selectedRelationship == rel,
onClick = { selectedRelationship = rel },
label = { Text("$emoji $rel") }
)
}
}
}
// Privacy note
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"All data stays on your device",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(name, dateOfBirth, selectedRelationship)
}
},
enabled = name.isNotBlank()
) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
// Date picker dialog
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
// Get selected date from date picker
// For now, set to current date as placeholder
dateOfBirth = System.currentTimeMillis()
showDatePicker = false
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("Cancel")
}
}
) {
// Material3 DatePicker
DatePicker(
state = rememberDatePickerState(),
modifier = Modifier.padding(16.dp)
RequirementItem(
icon = Icons.Default.HighQuality,
text = "Good quality images"
)
}
}
}
private fun formatDate(timestamp: Long): String {
val formatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return formatter.format(Date(timestamp))
@Composable
private fun RequirementItem(
icon: ImageVector,
text: String
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}

View File

@@ -17,77 +17,64 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.utilities.stats.StatsScreen
/**
* PhotoUtilitiesScreen - UPDATED with Stats tab
* CLEANED PhotoUtilitiesScreen - No duplicate header
*
* Removed:
* - Scaffold wrapper (lines 36-74)
* - TopAppBar (was creating banner)
* - "Photo Utilities" title (MainScreen shows it)
*
* Features:
* - Stats tab (photo statistics and analytics)
* - Tools tab (scan, duplicates, bursts, quality)
* - Clean TabRow navigation
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhotoUtilitiesScreen(
viewModel: PhotoUtilitiesViewModel = hiltViewModel()
viewModel: PhotoUtilitiesViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle()
var selectedTab by remember { mutableStateOf(0) }
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
Column {
Text(
"Photo Utilities",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"Manage your photo collection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
)
)
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("Stats") },
icon = { Icon(Icons.Default.BarChart, "Statistics") }
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("Tools") },
icon = { Icon(Icons.Default.Build, "Tools") }
)
}
}
Column(modifier = modifier.fillMaxSize()) {
// TabRow for Stats/Tools
TabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("Stats") },
icon = { Icon(Icons.Default.BarChart, "Statistics") }
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("Tools") },
icon = { Icon(Icons.Default.Build, "Tools") }
)
}
) { paddingValues ->
// Tab content
when (selectedTab) {
0 -> {
// Stats tab - delegate to StatsScreen
// Stats tab
StatsScreen()
}
1 -> {
// Tools tab - existing utilities
// Tools tab
ToolsTabContent(
uiState = uiState,
scanProgress = scanProgress,
onScanPhotos = { viewModel.scanForPhotos() },
onDetectDuplicates = { viewModel.detectDuplicates() },
onDetectBursts = { viewModel.detectBursts() },
onAnalyzeQuality = { viewModel.analyzeQuality() },
modifier = Modifier.padding(paddingValues)
onAnalyzeQuality = { viewModel.analyzeQuality() }
)
}
}
@@ -257,13 +244,13 @@ private fun SectionHeader(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 4.dp)
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(20.dp)
)
Text(
text = title,
@@ -285,52 +272,53 @@ private fun UtilityCard(
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Icon
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(56.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.Center) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp)
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Text
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Button
Button(
onClick = onClick,
enabled = enabled
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
shape = RoundedCornerShape(12.dp)
) {
Text(buttonText)
}
@@ -343,43 +331,34 @@ private fun ProgressCard(progress: ScanProgress) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = progress.message,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
if (progress.total > 0) {
Text(
text = "${progress.current} / ${progress.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = "${progress.current}/${progress.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (progress.total > 0) {
LinearProgressIndicator(
progress = { progress.current.toFloat() / progress.total.toFloat() },
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
LinearProgressIndicator(
progress = { progress.current.toFloat() / progress.total.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -393,15 +372,11 @@ private fun ResultCard(
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = iconTint.copy(alpha = 0.1f)
)
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -410,9 +385,7 @@ private fun ResultCard(
tint = iconTint,
modifier = Modifier.size(32.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
@@ -420,7 +393,8 @@ private fun ResultCard(
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -432,62 +406,25 @@ private fun InfoCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "How It Works",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
InfoItem(
"Duplicates",
"Finds exact duplicates by comparing file content"
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(20.dp)
)
InfoItem(
"Bursts",
"Groups 3+ photos taken within 2 seconds. Tags one as 'representative' for albums"
)
InfoItem(
"Quality",
"Detects screenshots by screen dimensions. Blurry detection coming soon"
Text(
text = "These tools help you organize and maintain your photo collection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@Composable
private fun InfoItem(title: String, description: String) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "$title",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp)
)
}
}