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 package com.placeholder.sherpai2.data.local.model
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation import androidx.room.Relation
import com.placeholder.sherpai2.data.local.entity.* 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( data class ImageWithEverything(
@Embedded @Embedded
val image: ImageEntity, val image: ImageEntity,
/**
* Tags for this image (via image_tags join table)
* Room automatically joins through ImageTagEntity
*/
@Relation( @Relation(
parentColumn = "imageId", 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( @Relation(
parentColumn = "imageId", parentColumn = "imageId",
entityColumn = "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.local.entity.PhotoFaceTagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.ui.search.DateRange import com.placeholder.sherpai2.ui.search.DateRange
import com.placeholder.sherpai2.ui.search.DisplayMode
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -25,8 +24,8 @@ import javax.inject.Inject
* Features: * Features:
* - Search within album * - Search within album
* - Date filtering * - Date filtering
* - Simple/Verbose toggle
* - Album stats * - Album stats
* - Export functionality
*/ */
@HiltViewModel @HiltViewModel
class AlbumViewModel @Inject constructor( class AlbumViewModel @Inject constructor(
@@ -54,10 +53,6 @@ class AlbumViewModel @Inject constructor(
private val _dateRange = MutableStateFlow(DateRange.ALL_TIME) private val _dateRange = MutableStateFlow(DateRange.ALL_TIME)
val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow() val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow()
// Display mode
private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE)
val displayMode: StateFlow<DisplayMode> = _displayMode.asStateFlow()
init { init {
loadAlbumData() loadAlbumData()
} }
@@ -93,7 +88,7 @@ class AlbumViewModel @Inject constructor(
combine( combine(
_searchQuery, _searchQuery,
_dateRange _dateRange
) { query, dateRange -> ) { query: String, dateRange: DateRange ->
Pair(query, dateRange) Pair(query, dateRange)
}.collectLatest { (query, dateRange) -> }.collectLatest { (query, dateRange) ->
val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f) val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f)
@@ -119,7 +114,7 @@ class AlbumViewModel @Inject constructor(
.distinctBy { it.id } .distinctBy { it.id }
_uiState.value = AlbumUiState.Success( _uiState.value = AlbumUiState.Success(
albumName = tag.value.replace("_", " ").capitalize(), albumName = tag.value.replace("_", " ").replaceFirstChar { it.uppercase() },
albumType = "Tag", albumType = "Tag",
photos = imagesWithFaces, photos = imagesWithFaces,
personCount = uniquePersons.size, personCount = uniquePersons.size,
@@ -138,7 +133,7 @@ class AlbumViewModel @Inject constructor(
combine( combine(
_searchQuery, _searchQuery,
_dateRange _dateRange
) { query, dateRange -> ) { query: String, dateRange: DateRange ->
Pair(query, dateRange) Pair(query, dateRange)
}.collectLatest { (query, dateRange) -> }.collectLatest { (query, dateRange) ->
val images = faceRecognitionRepository.getImagesForPerson(albumId) val images = faceRecognitionRepository.getImagesForPerson(albumId)
@@ -184,7 +179,7 @@ class AlbumViewModel @Inject constructor(
combine( combine(
_searchQuery, _searchQuery,
_dateRange _dateRange
) { query, _ -> ) { query: String, _: DateRange ->
query query
}.collectLatest { query -> }.collectLatest { query ->
val images = imageDao.getImagesInRange(startTime, endTime) val images = imageDao.getImagesInRange(startTime, endTime)
@@ -224,13 +219,6 @@ class AlbumViewModel @Inject constructor(
_dateRange.value = range _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 { private fun isInDateRange(timestamp: Long, range: DateRange): Boolean {
return when (range) { return when (range) {
DateRange.ALL_TIME -> true DateRange.ALL_TIME -> true
@@ -311,10 +299,6 @@ class AlbumViewModel @Inject constructor(
set(Calendar.MILLISECOND, 0) set(Calendar.MILLISECOND, 0)
}.timeInMillis }.timeInMillis
} }
private fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}
} }
sealed class AlbumUiState { sealed class AlbumUiState {

View File

@@ -1,11 +1,9 @@
package com.placeholder.sherpai2.ui.album package com.placeholder.sherpai2.ui.album
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.* import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -13,23 +11,24 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.placeholder.sherpai2.ui.search.DateRange 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: * REMOVED:
* - PhotoCard now clickable * - DisplayMode toggle
* - Passes onImageClick to ImageGridItem * - Verbose person tags
* - Entire card surface clickable as backup *
* ADDED:
* - Export menu (Folder, Zip, Collage)
* - Clean simple layout
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -41,7 +40,8 @@ fun AlbumViewScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val dateRange by viewModel.dateRange.collectAsStateWithLifecycle() val dateRange by viewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by viewModel.displayMode.collectAsStateWithLifecycle()
var showExportMenu by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -73,15 +73,9 @@ fun AlbumViewScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { viewModel.toggleDisplayMode() }) { // Export button
Icon( IconButton(onClick = { showExportMenu = true }) {
imageVector = if (displayMode == DisplayMode.SIMPLE) { Icon(Icons.Default.FileDownload, "Export")
Icons.Default.ViewList
} else {
Icons.Default.ViewModule
},
contentDescription = "Toggle view"
)
} }
} }
) )
@@ -127,7 +121,6 @@ fun AlbumViewScreen(
state = state, state = state,
searchQuery = searchQuery, searchQuery = searchQuery,
dateRange = dateRange, dateRange = dateRange,
displayMode = displayMode,
onSearchChange = { viewModel.setSearchQuery(it) }, onSearchChange = { viewModel.setSearchQuery(it) },
onDateRangeChange = { viewModel.setDateRange(it) }, onDateRangeChange = { viewModel.setDateRange(it) },
onImageClick = onImageClick, 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 @Composable
@@ -143,7 +163,6 @@ private fun AlbumContent(
state: AlbumUiState.Success, state: AlbumUiState.Success,
searchQuery: String, searchQuery: String,
dateRange: DateRange, dateRange: DateRange,
displayMode: DisplayMode,
onSearchChange: (String) -> Unit, onSearchChange: (String) -> Unit,
onDateRangeChange: (DateRange) -> Unit, onDateRangeChange: (DateRange) -> Unit,
onImageClick: (String) -> Unit, onImageClick: (String) -> Unit,
@@ -206,7 +225,8 @@ private fun AlbumContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(DateRange.entries) { range -> items(DateRange.entries.size) { index ->
val range = DateRange.entries[index]
val isActive = dateRange == range val isActive = dateRange == range
FilterChip( FilterChip(
selected = isActive, selected = isActive,
@@ -244,7 +264,6 @@ private fun AlbumContent(
) { photo -> ) { photo ->
PhotoCard( PhotoCard(
photo = photo, photo = photo,
displayMode = displayMode,
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
@@ -254,7 +273,11 @@ private fun AlbumContent(
} }
@Composable @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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp) 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 @Composable
private fun PhotoCard( private fun PhotoCard(
photo: AlbumPhoto, photo: AlbumPhoto,
displayMode: DisplayMode,
onImageClick: (String) -> Unit onImageClick: (String) -> Unit
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onImageClick(photo.image.imageUri) }, // ✅ ENTIRE CARD CLICKABLE .aspectRatio(1f)
.clickable { onImageClick(photo.image.imageUri) },
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Column { Box {
// Image (also clickable via ImageGridItem) // Image
ImageGridItem( AsyncImage(
image = photo.image, model = photo.image.imageUri,
onClick = { onImageClick(photo.image.imageUri) } // ✅ IMAGE CLICKABLE contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
) )
// Person tags // Person names overlay (if any)
if (photo.persons.isNotEmpty()) { if (photo.persons.isNotEmpty()) {
when (displayMode) {
DisplayMode.SIMPLE -> {
Surface( Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
modifier = Modifier.fillMaxWidth() modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
) { ) {
Text( Text(
text = photo.persons.take(3).joinToString(", ") { it.name }, text = photo.persons.take(2).joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Medium
) )
} }
} }
DisplayMode.VERBOSE -> { }
Surface( }
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), }
modifier = Modifier.fillMaxWidth()
) { /**
Column( * Export Dialog
modifier = Modifier.padding(8.dp), */
verticalArrangement = Arrangement.spacedBy(4.dp) @Composable
) { private fun ExportDialog(
photo.persons.take(3).forEachIndexed { index, person -> albumName: String,
Row( photoCount: Int,
horizontalArrangement = Arrangement.spacedBy(6.dp), onDismiss: () -> Unit,
verticalAlignment = Alignment.CenterVertically onExportToFolder: () -> Unit,
) { onExportToZip: () -> Unit,
Icon( onExportToCollage: () -> Unit
Icons.Default.Face, ) {
null, AlertDialog(
Modifier.size(14.dp), onDismissRequest = onDismiss,
MaterialTheme.colorScheme.primary icon = { Icon(Icons.Default.FileDownload, null) },
) title = { Text("Export Album") },
Text( text = {
text = person.name, Column(
style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)
maxLines = 1, ) {
overflow = TextOverflow.Ellipsis Text(
) "$photoCount photos from \"$albumName\"",
if (index < photo.faceTags.size) { style = MaterialTheme.typography.bodyMedium,
val confidence = (photo.faceTags[index].confidence * 100).toInt() color = MaterialTheme.colorScheme.onSurfaceVariant
Text( )
text = "$confidence%",
style = MaterialTheme.typography.labelSmall, // Export to Folder
color = MaterialTheme.colorScheme.primary 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 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: * Features:
* - Rectangular album cards (more compact) * - Rectangular album cards (compact)
* - Stories section (recent highlights) * - Stories section (recent highlights)
* - Clickable navigation to AlbumViewScreen * - Clickable navigation to AlbumViewScreen
* - Beautiful gradients and icons * - Beautiful gradients and icons
* - Mobile-friendly scrolling
*/ */
@Composable @Composable
fun ExploreScreen( fun ExploreScreen(
onAlbumClick: (albumType: String, albumId: String) -> Unit, onAlbumClick: (albumType: String, albumId: String) -> Unit,
viewModel: ExploreViewModel = hiltViewModel() viewModel: ExploreViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
Column( Box(modifier = modifier.fillMaxSize()) {
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
) {
// Header with gradient
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.surface
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Explore",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Your photo collection organized",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
when (val state = uiState) { when (val state = uiState) {
is ExploreViewModel.ExploreUiState.Loading -> { is ExploreViewModel.ExploreUiState.Loading -> {
Box( Box(
@@ -83,12 +54,18 @@ fun ExploreScreen(
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
is ExploreViewModel.ExploreUiState.Success -> { is ExploreViewModel.ExploreUiState.Success -> {
if (state.smartAlbums.isEmpty()) {
EmptyExploreView()
} else {
ExploreContent( ExploreContent(
smartAlbums = state.smartAlbums, smartAlbums = state.smartAlbums,
onAlbumClick = onAlbumClick onAlbumClick = onAlbumClick
) )
} }
}
is ExploreViewModel.ExploreUiState.Error -> { is ExploreViewModel.ExploreUiState.Error -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -96,17 +73,25 @@ fun ExploreScreen(
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(32.dp)
) { ) {
Icon( Icon(
Icons.Default.Error, Icons.Default.Error,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
Text(
text = "Error Loading Albums",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text( Text(
text = state.message, 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 @Composable
private fun ExploreContent( private fun ExploreContent(
smartAlbums: List<SmartAlbum>, smartAlbums: List<SmartAlbum>,
@@ -127,11 +115,14 @@ private fun ExploreContent(
) { ) {
// Stories Section (Recent Highlights) // Stories Section (Recent Highlights)
item { item {
val storyAlbums = smartAlbums.filter { it.imageCount > 0 }.take(10)
if (storyAlbums.isNotEmpty()) {
StoriesSection( StoriesSection(
albums = smartAlbums.filter { it.imageCount > 0 }.take(10), albums = storyAlbums,
onAlbumClick = onAlbumClick onAlbumClick = onAlbumClick
) )
} }
}
// Time-based Albums // Time-based Albums
val timeAlbums = smartAlbums.filterIsInstance<SmartAlbum.TimeRange>() val timeAlbums = smartAlbums.filterIsInstance<SmartAlbum.TimeRange>()
@@ -225,7 +216,7 @@ private fun ExploreContent(
} }
/** /**
* Stories section - Instagram-style circular highlights * Stories section - circular album previews
*/ */
@Composable @Composable
private fun StoriesSection( private fun StoriesSection(
@@ -294,7 +285,8 @@ private fun StoryCircle(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 2, maxLines = 2,
modifier = Modifier.width(80.dp), modifier = Modifier.width(80.dp),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
) )
Text( Text(
@@ -342,7 +334,7 @@ private fun AlbumSection(
} }
/** /**
* Rectangular album card - more compact than square * Rectangular album card - compact design
*/ */
@Composable @Composable
private fun AlbumCard( 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 * 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import java.text.SimpleDateFormat
import java.util.*
/** /**
* PersonInventoryScreen - Manage trained face models * CLEANED PersonInventoryScreen - No duplicate header
* *
* Features: * Removed:
* - List all trained persons * - Scaffold wrapper
* - View stats * - TopAppBar (was creating banner)
* - DELETE models * - "Trained People" title (MainScreen shows it)
* - SCAN LIBRARY to find person in all photos (NEW!) *
* 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 @Composable
fun PersonInventoryScreen( fun PersonInventoryScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -44,316 +43,171 @@ fun PersonInventoryScreen(
var personToDelete by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) } var personToDelete by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) }
var personToScan by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) } var personToScan by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) }
Scaffold( LazyColumn(
topBar = { modifier = modifier.fillMaxSize(),
TopAppBar( contentPadding = PaddingValues(16.dp),
title = { Text("Trained People") }, verticalArrangement = Arrangement.spacedBy(12.dp)
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) { when (val state = uiState) {
is PersonInventoryViewModel.InventoryUiState.Loading -> { is PersonInventoryViewModel.InventoryUiState.Loading -> {
LoadingView() item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} }
is PersonInventoryViewModel.InventoryUiState.Success -> { is PersonInventoryViewModel.InventoryUiState.Success -> {
if (state.persons.isEmpty()) { // Summary card
EmptyView() item {
} else { SummaryCard(
PersonListView( peopleCount = state.persons.size,
persons = state.persons, totalPhotos = state.persons.sumOf { it.stats.taggedPhotoCount }
onDeleteClick = { personToDelete = it },
onScanClick = { personToScan = it },
onViewPhotos = { onViewPersonPhotos(it.person.id) },
scanningState = scanningState
) )
} }
// 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 -> { is PersonInventoryViewModel.InventoryUiState.Error -> {
ErrorView( item {
message = state.message, ErrorCard(message = state.message)
onRetry = { viewModel.loadPersons() }
)
} }
} }
// Scanning overlay
if (scanningState is PersonInventoryViewModel.ScanningState.Scanning) {
ScanningOverlay(scanningState as PersonInventoryViewModel.ScanningState.Scanning)
}
} }
} }
// Delete confirmation dialog // Delete confirmation
personToDelete?.let { personWithStats -> personToDelete?.let { person ->
AlertDialog( DeleteDialog(
onDismissRequest = { personToDelete = null }, person = person,
title = { Text("Delete ${personWithStats.person.name}?") }, onDismiss = { personToDelete = null },
text = { onConfirm = {
Text( viewModel.deletePerson(person.person.id, person.stats.faceModelId)
"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 personToDelete = null
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { personToDelete = null }) {
Text("Cancel")
}
} }
) )
} }
// Scan library confirmation dialog // Scan confirmation
personToScan?.let { personWithStats -> personToScan?.let { person ->
AlertDialog( ScanDialog(
onDismissRequest = { personToScan = null }, person = person,
icon = { Icon(Icons.Default.Search, contentDescription = null) }, onDismiss = { personToScan = null },
title = { Text("Scan Library for ${personWithStats.person.name}?") }, onConfirm = {
text = { viewModel.scanLibraryForPerson(person.person.id, person.stats.faceModelId)
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 personToScan = null
} }
) {
Icon(Icons.Default.Search, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Scan")
}
},
dismissButton = {
TextButton(onClick = { personToScan = null }) {
Text("Cancel")
}
}
) )
} }
} }
/**
* Summary card with stats
*/
@Composable @Composable
private fun LoadingView() { private fun SummaryCard(peopleCount: Int, totalPhotos: Int) {
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(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Summary card
item {
SummaryCard(totalPersons = persons.size)
Spacer(modifier = Modifier.height(8.dp))
}
// 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
)
}
}
}
@Composable
private fun SummaryCard(totalPersons: Int) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
) )
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.SpaceEvenly
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( StatItem(
Icons.Default.Face, icon = Icons.Default.People,
contentDescription = null, value = peopleCount.toString(),
modifier = Modifier.size(48.dp), label = "People"
tint = MaterialTheme.colorScheme.primary
) )
Column { VerticalDivider(
Text( modifier = Modifier.height(56.dp),
text = "$totalPersons trained ${if (totalPersons == 1) "person" else "people"}", color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
) )
Text( StatItem(
text = "Face recognition models ready", icon = Icons.Default.PhotoLibrary,
style = MaterialTheme.typography.bodyMedium, value = totalPhotos.toString(),
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) label = "Tagged"
) )
} }
} }
}
} }
@Composable @Composable
private fun PersonCard( private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, label: String) {
personWithStats: PersonInventoryViewModel.PersonWithStats, Column(
onDeleteClick: () -> Unit, horizontalAlignment = Alignment.CenterHorizontally,
onScanClick: () -> Unit, verticalArrangement = Arrangement.spacedBy(4.dp)
onViewPhotos: () -> Unit, ) {
isScanning: Boolean Icon(
) { icon,
val stats = personWithStats.stats 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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(16.dp),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(12.dp)
.padding(16.dp)
) { ) {
// Header: Name and actions // Header row
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -363,38 +217,39 @@ private fun PersonCard(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( // Avatar
modifier = Modifier Surface(
.size(48.dp) modifier = Modifier.size(48.dp),
.clip(CircleShape) shape = CircleShape,
.background(MaterialTheme.colorScheme.primary), color = MaterialTheme.colorScheme.primaryContainer
contentAlignment = Alignment.Center
) { ) {
Text( Box(contentAlignment = Alignment.Center) {
text = personWithStats.person.name.take(1).uppercase(), Icon(
style = MaterialTheme.typography.titleLarge, Icons.Default.Person,
fontWeight = FontWeight.Bold, contentDescription = null,
color = MaterialTheme.colorScheme.onPrimary modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
) )
} }
}
// Name and stats
Column { Column {
Text( Text(
text = personWithStats.person.name, text = person.person.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = "ID: ${personWithStats.person.id.take(8)}", text = "${person.stats.taggedPhotoCount} photos • ${person.stats.trainingImageCount} trained",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
IconButton(onClick = onDeleteClick) { // Delete button
IconButton(onClick = onDelete) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = "Delete", contentDescription = "Delete",
@@ -403,212 +258,251 @@ private fun PersonCard(
} }
} }
Spacer(modifier = Modifier.height(16.dp)) // Action buttons
// 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
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Scan Library button (PRIMARY ACTION) OutlinedButton(
Button( onClick = onScan,
onClick = onScanClick, modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
enabled = !isScanning,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) { ) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon( Icon(
Icons.Default.Search, Icons.Default.Search,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} Spacer(Modifier.width(4.dp))
Spacer(modifier = Modifier.width(8.dp)) Text("Scan")
Text(if (isScanning) "Scanning..." else "Scan Library")
} }
// View photos button Button(
if (stats.taggedPhotoCount > 0) {
OutlinedButton(
onClick = onViewPhotos, onClick = onViewPhotos,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Icon( Icon(
Icons.Default.Photo, Icons.Default.PhotoLibrary,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(Modifier.width(4.dp))
Text("View (${stats.taggedPhotoCount})") 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 @Composable
private fun ScanningOverlay(state: PersonInventoryViewModel.ScanningState.Scanning) { private fun ScanningProgressCard(scanningState: PersonInventoryViewModel.ScanningState.Scanning) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
contentAlignment = Alignment.Center
) {
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth(0.85f) colors = CardDefaults.cardColors(
.padding(24.dp), containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) )
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Scanning for ${scanningState.personName}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
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()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Matches found: ${scanningState.facesFound}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
"Faces: ${scanningState.facesDetected}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Empty state
*/
@Composable
private fun EmptyState() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) { ) {
Column( Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Icon( Icon(
Icons.Default.Search, Icons.Default.PersonOff,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) )
Text( Text(
text = "Scanning Library", "No People Trained",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = "Finding ${state.personName} in your photos...", "Train face recognition to find people in your photos",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
) )
}
}
}
LinearProgressIndicator( /**
progress = { state.progress / state.total.toFloat() }, * Error card
*/
@Composable
private fun ErrorCard(message: String) {
Card(
modifier = Modifier.fillMaxWidth(), 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( Text(
text = "${state.progress} / ${state.total} photos scanned", message,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
) )
Text(
text = "${state.facesFound} faces detected",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} }
} }
} }
private fun formatDate(timestamp: Long): String { /**
val formatter = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) * Delete confirmation dialog
return formatter.format(Date(timestamp)) */
@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 package com.placeholder.sherpai2.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.* import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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: * Features:
* - Near-match search ("low" → "low_res") * - Include/Exclude people (visual chips)
* - Quick tag filter chips * - Include/Exclude tags (visual chips)
* - Date range filtering * - Clear visual distinction (green = include, red = exclude)
* - Clean person-only display * - Real-time filtering
* - Simple/Verbose toggle * - OpenSearch-style query building
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -37,107 +36,85 @@ fun SearchScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchViewModel: SearchViewModel, searchViewModel: SearchViewModel,
onImageClick: (String) -> Unit, onImageClick: (String) -> Unit,
onAlbumClick: (String) -> Unit = {} // For opening album view onAlbumClick: ((String) -> Unit)? = null
) { ) {
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() 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 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 val images by searchViewModel
.searchImages() .searchImages()
.collectAsStateWithLifecycle(initialValue = emptyList()) .collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold { paddingValues -> var showPeoplePicker by remember { mutableStateOf(false) }
Column( var showTagPicker by remember { mutableStateOf(false) }
modifier = modifier
.fillMaxSize() Column(modifier = modifier.fillMaxSize()) {
.padding(paddingValues) // Search bar + quick add buttons
) {
// 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( Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
horizontalArrangement = Arrangement.SpaceBetween, .fillMaxWidth()
modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
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
)
}
}
// Search bar
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
onValueChange = { searchViewModel.setSearchQuery(it) }, onValueChange = { searchViewModel.setSearchQuery(it) },
placeholder = { Text("Search... (e.g., 'low', 'gro', 'nig')") }, placeholder = { Text("Search tags...") },
leadingIcon = { leadingIcon = { Icon(Icons.Default.Search, null) },
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = { trailingIcon = {
if (searchQuery.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchViewModel.setSearchQuery("") }) { IconButton(onClick = { searchViewModel.setSearchQuery("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear") Icon(Icons.Default.Close, "Clear")
} }
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.weight(1f),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(12.dp)
) )
// Add person button
IconButton(
onClick = { showPeoplePicker = true },
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
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")
} }
} }
// Quick Tag Filters // Active filters display (chips)
if (systemTags.isNotEmpty()) { if (searchViewModel.hasActiveFilters()) {
Column( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp) .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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -145,303 +122,421 @@ fun SearchScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Quick Filters", "Active Filters",
style = MaterialTheme.typography.labelLarge, 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
)
// Results
if (images.isEmpty() && searchQuery.isBlank() && activeTagFilters.isEmpty()) {
EmptySearchState()
} else if (images.isEmpty()) {
NoResultsState(
query = searchQuery,
hasFilters = activeTagFilters.isNotEmpty() || dateRange != DateRange.ALL_TIME
)
} else {
Column {
// Results header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${images.size} ${if (images.size == 1) "photo" else "photos"}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
// View Album button (if search results can be grouped)
if (activeTagFilters.size == 1 || searchQuery.isNotBlank()) {
TextButton( TextButton(
onClick = { onClick = { searchViewModel.clearAllFilters() },
val albumTag = activeTagFilters.firstOrNull() ?: searchQuery contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
onAlbumClick(albumTag)
}
) { ) {
Icon( Text("Clear All", style = MaterialTheme.typography.labelMedium)
Icons.Default.Collections, }
contentDescription = null, }
modifier = Modifier.size(16.dp)
// 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
) )
Spacer(Modifier.width(4.dp)) )
Text("View Album") }
} }
} }
} }
// Photo grid // 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( LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp), columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(12.dp), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize() verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items( items(
items = images, items = images,
key = { it.image.imageId } key = { it.image.imageUri }
) { imageWithFaceTags -> ) { imageWithTags ->
PhotoCard( Card(
imageWithFaceTags = imageWithFaceTags, modifier = Modifier
displayMode = displayMode, .aspectRatio(1f)
onImageClick = onImageClick .clickable { onImageClick(imageWithTags.image.imageUri) }
) {
AsyncImage(
model = imageWithTags.image.imageUri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
) )
} }
} }
} }
} }
} }
// 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 }
)
} }
} }
/**
* Photo card with clean person display
*/
@Composable @Composable
private fun PhotoCard( private fun FilterChip(
imageWithFaceTags: ImageWithFaceTags, selected: Boolean,
displayMode: DisplayMode, onClick: () -> Unit,
onImageClick: (String) -> Unit onLongClick: (() -> Unit)? = null,
label: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: androidx.compose.material3.SelectableChipColors = FilterChipDefaults.filterChipColors()
) { ) {
Card( androidx.compose.material3.FilterChip(
modifier = Modifier.fillMaxWidth(), selected = selected,
shape = RoundedCornerShape(12.dp), onClick = onClick,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) label = label,
) { leadingIcon = leadingIcon,
Column { trailingIcon = trailingIcon,
// Image colors = colors
ImageGridItem(
image = imageWithFaceTags.image,
onClick = { onImageClick(imageWithFaceTags.image.imageUri) }
) )
}
// Person tags (deduplicated) @Composable
val uniquePersons = imageWithFaceTags.persons.distinctBy { it.id } private fun PeoplePickerDialog(
people: List<com.placeholder.sherpai2.data.local.entity.PersonEntity>,
if (uniquePersons.isNotEmpty()) { includedPeople: Set<String>,
when (displayMode) { excludedPeople: Set<String>,
DisplayMode.SIMPLE -> { onInclude: (String) -> Unit,
// SIMPLE: Just names, no icons, no percentages onExclude: (String) -> Unit,
Surface( onDismiss: () -> Unit
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), ) {
modifier = Modifier.fillMaxWidth() AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add People Filter") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
text = uniquePersons "Tap to INCLUDE (green) • Long press to EXCLUDE (red)",
.take(3)
.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp), color = MaterialTheme.colorScheme.onSurfaceVariant
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
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
} }
} )
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( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Text(person.name, fontWeight = FontWeight.Medium)
Icons.Default.Face, Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
contentDescription = null, IconButton(
modifier = Modifier.size(14.dp), onClick = { onInclude(person.id) },
tint = MaterialTheme.colorScheme.primary 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( Text(
text = person.name, "Tap to INCLUDE (green) • Long press to EXCLUDE (red)",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant
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
)
}
// 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)
) )
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( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(12.dp),
modifier = Modifier.fillMaxWidth() horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
// Example system tags - replace with actual tags from image Text(tagValue, fontWeight = FontWeight.Medium)
SystemTagChip("indoor") Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SystemTagChip("high_res") IconButton(
SystemTagChip("morning") onClick = { onInclude(tagValue) },
} colors = IconButtonDefaults.iconButtonColors(
} containerColor = if (isIncluded) Color(0xFF4CAF50) else Color.Transparent
}
}
}
}
}
}
}
@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)
) )
) {
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 @Composable
private fun EmptySearchState() { private fun EmptyState() {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)
modifier = Modifier.padding(32.dp)
) { ) {
Icon( Icon(
Icons.Default.Search, Icons.Default.Search,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(80.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) )
Text( Text(
text = "Search or filter photos", "Advanced Search",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = "Try searching or tapping quick filters", "Add people and tags to build your search",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -450,67 +545,33 @@ private fun EmptySearchState() {
} }
@Composable @Composable
private fun NoResultsState(query: String, hasFilters: Boolean) { private fun NoResultsState() {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(32.dp)
) { ) {
Icon( Icon(
Icons.Default.SearchOff, Icons.Default.SearchOff,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(80.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f) tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) )
Text( Text(
text = "No results found", "No photos found",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
if (query.isNotBlank()) {
Text( Text(
text = "No matches for \"$query\"", "Try different filters",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (hasFilters) {
Text(
text = "Try removing some filters",
style = MaterialTheme.typography.bodySmall,
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.ViewModel
import androidx.lifecycle.viewModelScope 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.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.domain.repository.ImageRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -16,220 +16,241 @@ import java.util.Calendar
import javax.inject.Inject import javax.inject.Inject
/** /**
* SearchViewModel - COMPLETE REDESIGN * OPTIMIZED SearchViewModel with Boolean Logic
* *
* Features: * PERFORMANCE: NO N+1 QUERIES!
* - Near-match search ("low" → "low_res", "gro" → "group_photo") * ✅ ImageAggregateDao loads tags via @Relation (1 query for 100 images!)
* - Date range filtering * ✅ Person cache for O(1) faceModelId lookups
* - Quick tag filters * ✅ All filtering in memory (FAST)
* - Clean person-only display
* - Simple/Verbose toggle
*/ */
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val imageRepository: ImageRepository, private val imageAggregateDao: ImageAggregateDao,
private val faceRecognitionRepository: FaceRecognitionRepository, private val faceRecognitionRepository: FaceRecognitionRepository,
private val personDao: PersonDao,
private val tagDao: TagDao private val tagDao: TagDao
) : ViewModel() { ) : ViewModel() {
// Search query with near-match support
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Active tag filters (quick chips) private val _includedPeople = MutableStateFlow<Set<String>>(emptySet())
private val _activeTagFilters = MutableStateFlow<Set<String>>(emptySet()) val includedPeople: StateFlow<Set<String>> = _includedPeople.asStateFlow()
val activeTagFilters: StateFlow<Set<String>> = _activeTagFilters.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) private val _dateRange = MutableStateFlow(DateRange.ALL_TIME)
val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow() val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow()
// Display mode (simple = names only, verbose = icons + percentages) private val _availablePeople = MutableStateFlow<List<PersonEntity>>(emptyList())
private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE) val availablePeople: StateFlow<List<PersonEntity>> = _availablePeople.asStateFlow()
val displayMode: StateFlow<DisplayMode> = _displayMode.asStateFlow()
// Available system tags for quick filters private val _availableTags = MutableStateFlow<List<String>>(emptyList())
private val _systemTags = MutableStateFlow<List<TagEntity>>(emptyList()) val availableTags: StateFlow<List<String>> = _availableTags.asStateFlow()
val systemTags: StateFlow<List<TagEntity>> = _systemTags.asStateFlow()
private val personCache = mutableMapOf<String, String>()
init { 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>> { fun searchImages(): Flow<List<ImageWithFaceTags>> {
return combine( return combine(
_searchQuery, _searchQuery,
_activeTagFilters, _includedPeople,
_excludedPeople,
_includedTags,
_excludedTags,
_dateRange _dateRange
) { query, tagFilters, dateRange -> ) { values: Array<*> ->
Triple(query, tagFilters, dateRange) SearchCriteria(
}.flatMapLatest { (query, tagFilters, dateRange) -> query = values[0] as String,
includedPeople = values[1] as Set<String>,
channelFlow { excludedPeople = values[2] as Set<String>,
// Get matching tags FIRST (suspend call) includedTags = values[3] as Set<String>,
val matchingTags = if (query.isNotBlank()) { excludedTags = values[4] as Set<String>,
findMatchingTags(query) dateRange = values[5] as DateRange
} 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 }
}
}
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
) )
}.flatMapLatest { criteria ->
imageAggregateDao.observeAllImagesWithEverything()
.map { imagesList ->
imagesList.mapNotNull { imageWithEverything ->
if (!isInDateRange(imageWithEverything.image.capturedAt, criteria.dateRange)) {
return@mapNotNull null
}
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( ImageWithFaceTags(
image = imageWithEverything.image, image = imageWithEverything.image,
faceTags = tagsWithPersons.map { it.first }, faceTags = imageWithEverything.faceTags,
persons = tagsWithPersons.map { it.second } persons = persons
) )
} else {
null
} }
.sortedByDescending { it.image.capturedAt } }.sortedByDescending { it.image.capturedAt }
send(filtered)
}
}
}
}
/**
* Near-match search: "low" matches "low_res", "gro" matches "group_photo"
*/
private suspend fun findMatchingTags(query: String): List<TagEntity> {
val normalizedQuery = query.trim().lowercase()
// Get all system tags
val allTags = tagDao.getByType("SYSTEM")
// Find tags that contain the query or match it closely
return allTags.filter { tag ->
val tagValue = tag.value.lowercase()
// Exact match
tagValue == normalizedQuery ||
// Contains match
tagValue.contains(normalizedQuery) ||
// Starts with match
tagValue.startsWith(normalizedQuery) ||
// Fuzzy match (remove underscores and compare)
tagValue.replace("_", "").contains(normalizedQuery.replace("_", ""))
}.sortedBy { tag ->
// Sort by relevance: exact > starts with > contains
when {
tag.value.lowercase() == normalizedQuery -> 0
tag.value.lowercase().startsWith(normalizedQuery) -> 1
else -> 2
} }
} }
} }
/** private fun applyBooleanLogic(
* Load available system tags for quick filters personIds: Set<String>,
*/ imageTags: Set<String>,
private fun loadSystemTags() { criteria: SearchCriteria
): Boolean {
val hasAllIncludedPeople = if (criteria.includedPeople.isNotEmpty()) {
criteria.includedPeople.all { it in personIds }
} else true
val hasNoExcludedPeople = if (criteria.excludedPeople.isNotEmpty()) {
criteria.excludedPeople.none { it in personIds }
} else true
val hasAllIncludedTags = if (criteria.includedTags.isNotEmpty()) {
criteria.includedTags.all { it in imageTags }
} else true
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
}
private fun loadAvailableFilters() {
viewModelScope.launch { 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 -> val tagsWithUsage = tags.map { tag ->
tag to tagDao.getTagUsageCount(tag.tagId) tag to tagDao.getTagUsageCount(tag.tagId)
} }
_availableTags.value = tagsWithUsage
// Sort by most commonly used
val sortedTags = tagsWithUsage
.sortedByDescending { (_, usageCount) -> usageCount } .sortedByDescending { (_, usageCount) -> usageCount }
.take(12) // Show top 12 most used tags .take(30)
.map { (tag, _) -> tag } .map { (tag, _) -> tag.value }
_systemTags.value = sortedTags
} }
} }
/** fun includePerson(personId: String) {
* Update search query _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) { fun setSearchQuery(query: String) {
_searchQuery.value = query _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) { fun setDateRange(range: DateRange) {
_dateRange.value = range _dateRange.value = range
} }
/** fun clearAllFilters() {
* Toggle display mode (simple/verbose) _searchQuery.value = ""
*/ _includedPeople.value = emptySet()
fun toggleDisplayMode() { _excludedPeople.value = emptySet()
_displayMode.value = when (_displayMode.value) { _includedTags.value = emptySet()
DisplayMode.SIMPLE -> DisplayMode.VERBOSE _excludedTags.value = emptySet()
DisplayMode.VERBOSE -> DisplayMode.SIMPLE _dateRange.value = DateRange.ALL_TIME
}
} }
/** fun hasActiveFilters(): Boolean {
* Check if timestamp is in date range return _searchQuery.value.isNotBlank() ||
*/ _includedPeople.value.isNotEmpty() ||
private fun isInDateRange(timestamp: Long, range: DateRange): Boolean { _excludedPeople.value.isNotEmpty() ||
return when (range) { _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.ALL_TIME -> true
DateRange.TODAY -> isToday(timestamp) DateRange.TODAY -> isToday(timestamp)
DateRange.THIS_WEEK -> isThisWeek(timestamp) DateRange.THIS_WEEK -> isThisWeek(timestamp)
DateRange.THIS_MONTH -> isThisMonth(timestamp) DateRange.THIS_MONTH -> isThisMonth(timestamp)
DateRange.THIS_YEAR -> isThisYear(timestamp) DateRange.THIS_YEAR -> isThisYear(timestamp)
} }
}
private fun isToday(timestamp: Long): Boolean { private fun isToday(timestamp: Long): Boolean {
val today = Calendar.getInstance() val today = Calendar.getInstance()
@@ -259,18 +280,21 @@ class SearchViewModel @Inject constructor(
} }
} }
/** private data class SearchCriteria(
* Data class containing image with face recognition data 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( data class ImageWithFaceTags(
val image: ImageEntity, val image: ImageEntity,
val faceTags: List<PhotoFaceTagEntity>, val faceTags: List<PhotoFaceTagEntity>,
val persons: List<PersonEntity> val persons: List<PersonEntity>
) )
/**
* Date range filters
*/
enum class DateRange(val displayName: String) { enum class DateRange(val displayName: String) {
ALL_TIME("All Time"), ALL_TIME("All Time"),
TODAY("Today"), TODAY("Today"),
@@ -279,10 +303,5 @@ enum class DateRange(val displayName: String) {
THIS_YEAR("This Year") THIS_YEAR("This Year")
} }
/** @Deprecated("No longer used")
* Display modes for photo tags enum class DisplayMode { SIMPLE, VERBOSE }
*/
enum class DisplayMode {
SIMPLE, // Just person names
VERBOSE // Names + icons + confidence percentages
}

View File

@@ -24,9 +24,24 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.placeholder.sherpai2.data.local.entity.TagWithUsage 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 @Composable
fun TagManagementScreen( fun TagManagementScreen(
viewModel: TagManagementViewModel = hiltViewModel() viewModel: TagManagementViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val scanningState by viewModel.scanningState.collectAsState() val scanningState by viewModel.scanningState.collectAsState()
@@ -35,105 +50,8 @@ fun TagManagementScreen(
var showScanMenu by remember { mutableStateOf(false) } var showScanMenu by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
Scaffold( Box(modifier = modifier.fillMaxSize()) {
floatingActionButton = { Column(modifier = Modifier.fillMaxSize()) {
// Single extended FAB with dropdown menu
var showMenu by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Dropdown menu for scan options
if (showMenu) {
Card(
modifier = Modifier.width(180.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column {
ListItem(
headlineContent = { Text("Scan All", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.AutoFixHigh,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForAllTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Base Tags", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.PhotoCamera,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForBaseTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Relationships", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.People,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForRelationshipTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Birthdays", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.Cake,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForBirthdayTags()
showMenu = false
}
)
}
}
}
// Main FAB
ExtendedFloatingActionButton(
onClick = { showMenu = !showMenu },
icon = {
Icon(
if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh,
"Scan"
)
},
text = { Text(if (showMenu) "Close" else "Scan Tags") }
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Stats Bar // Stats Bar
StatsBar(uiState) StatsBar(uiState)
@@ -166,16 +84,30 @@ fun TagManagementScreen(
} }
} }
is TagManagementViewModel.TagUiState.Success -> { is TagManagementViewModel.TagUiState.Success -> {
if (state.tags.isEmpty()) {
EmptyTagsView()
} else {
TagList( TagList(
tags = state.tags, tags = state.tags,
onDeleteTag = { viewModel.deleteTag(it) } onDeleteTag = { viewModel.deleteTag(it) }
) )
} }
}
is TagManagementViewModel.TagUiState.Error -> { is TagManagementViewModel.TagUiState.Error -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text( Text(
text = state.message, text = state.message,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
@@ -186,6 +118,28 @@ fun TagManagementScreen(
} }
} }
// 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 // Add Tag Dialog
if (showAddTagDialog) { if (showAddTagDialog) {
AddTagDialog( AddTagDialog(
@@ -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 @Composable
private fun StatsBar(uiState: TagManagementViewModel.TagUiState) { private fun StatsBar(uiState: TagManagementViewModel.TagUiState) {
if (uiState is TagManagementViewModel.TagUiState.Success) { val (totalTags, totalPhotos) = when (uiState) {
Card( is TagManagementViewModel.TagUiState.Success -> {
modifier = Modifier val photoCount: Int = uiState.tags.sumOf { it.usageCount }
.fillMaxWidth() uiState.tags.size to photoCount
.padding(16.dp), }
colors = CardDefaults.cardColors( else -> 0 to 0
containerColor = MaterialTheme.colorScheme.primaryContainer }
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.SpaceAround horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
StatItem("Total", uiState.totalTags.toString(), Icons.Default.Label) StatItem(
StatItem("System", uiState.systemTags.toString(), Icons.Default.AutoAwesome) icon = Icons.Default.Label,
StatItem("User", uiState.userTags.toString(), Icons.Default.PersonOutline) 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 @Composable
private fun StatItem(label: String, value: String, icon: ImageVector) { private fun StatItem(icon: ImageVector, value: String, label: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon( Icon(
imageVector = icon, icon,
contentDescription = null, 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(
text = value, value,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = label, label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
/**
* Search bar
*/
@Composable @Composable
private fun SearchBar( private fun SearchBar(
searchQuery: String, searchQuery: String,
@@ -273,9 +231,9 @@ private fun SearchBar(
onValueChange = onSearchChange, onValueChange = onSearchChange,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(16.dp),
placeholder = { Text("Search tags...") }, placeholder = { Text("Search tags...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = { trailingIcon = {
if (searchQuery.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchChange("") }) { IconButton(onClick = { onSearchChange("") }) {
@@ -283,96 +241,124 @@ private fun SearchBar(
} }
} }
}, },
singleLine = true singleLine = true,
shape = RoundedCornerShape(16.dp)
) )
} }
/**
* Scanning progress indicator
*/
@Composable @Composable
private fun ScanningProgress( private fun ScanningProgress(
scanningState: TagManagementViewModel.TagScanningState, scanningState: TagManagementViewModel.TagScanningState,
viewModel: TagManagementViewModel viewModel: TagManagementViewModel
) { ) {
when (scanningState) {
is TagManagementViewModel.TagScanningState.Scanning -> {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
) )
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Scanning: ${scanningState.scanType}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) { ) {
when (scanningState) { Row(
is TagManagementViewModel.TagScanningState.Scanning -> { 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( Text(
text = "Scanning: ${scanningState.scanType.name.replace("_", " ")}", "Scan Complete!",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold 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(
text = "${scanningState.progress} / ${scanningState.total} images", "Processed: ${scanningState.imagesProcessed} images",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
Text( Text(
text = "Tags applied: ${scanningState.tagsApplied}", "Applied: ${scanningState.tagsApplied} tags",
style = MaterialTheme.typography.bodySmall
)
if (scanningState.newTagsCreated > 0) {
Text(
"Created: ${scanningState.newTagsCreated} new tags",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
is TagManagementViewModel.TagScanningState.Complete -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "✓ Scan Complete",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "${scanningState.tagsApplied} tags applied to ${scanningState.imagesProcessed} images",
style = MaterialTheme.typography.bodySmall
)
}
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
} }
} }
} }
is TagManagementViewModel.TagScanningState.Error -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Error: ${scanningState.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
}
}
}
else -> { /* Idle - don't show */ }
}
} }
else -> {}
} }
} }
/**
* Tag list
*/
@Composable @Composable
private fun TagList( private fun TagList(
tags: List<TagWithUsage>, tags: List<TagWithUsage>,
@@ -383,114 +369,238 @@ private fun TagList(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(tags, key = { it.tagId }) { tag -> items(tags) { tagWithUsage ->
TagListItem(tag, onDeleteTag) TagCard(
tagWithUsage = tagWithUsage,
onDelete = { onDeleteTag(tagWithUsage.tagId) }
)
} }
} }
} }
/**
* Individual tag card
*/
@Composable @Composable
private fun TagListItem( private fun TagCard(
tag: TagWithUsage, tagWithUsage: TagWithUsage,
onDeleteTag: (String) -> Unit onDelete: () -> Unit
) { ) {
var showDeleteConfirm by remember { mutableStateOf(false) } val isSystemTag = tagWithUsage.type == "SYSTEM"
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { /* TODO: Navigate to images with this tag */ } elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.padding(16.dp),
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Tag type icon // Tag icon
Icon( Surface(
imageVector = if (tag.type == "SYSTEM") Icons.Default.AutoAwesome else Icons.Default.Label, modifier = Modifier.size(40.dp),
contentDescription = null, shape = RoundedCornerShape(8.dp),
tint = if (tag.type == "SYSTEM") color = if (isSystemTag)
MaterialTheme.colorScheme.secondary MaterialTheme.colorScheme.primaryContainer
else 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 { Column {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = tag.value, text = tagWithUsage.value,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
if (isSystemTag) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Text( Text(
text = if (tag.type == "SYSTEM") "System tag" else "User tag", "SYSTEM",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
Text(
text = "${tagWithUsage.usageCount} ${if (tagWithUsage.usageCount == 1) "photo" else "photos"}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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) // Delete button (only for user tags)
if (tag.type == "GENERIC") { if (!isSystemTag) {
IconButton(onClick = { showDeleteConfirm = true }) { IconButton(onClick = onDelete) {
Icon( Icon(
Icons.Default.Delete, Icons.Default.Delete,
contentDescription = "Delete tag", contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
} }
} }
} }
}
/**
* 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
)
}
} }
if (showDeleteConfirm) { // Main FAB
AlertDialog( ExtendedFloatingActionButton(
onDismissRequest = { showDeleteConfirm = false }, onClick = onToggleMenu,
title = { Text("Delete Tag?") }, icon = {
text = { Text("Are you sure you want to delete '${tag.value}'? This will remove it from ${tag.usageCount} images.") }, Icon(
confirmButton = { if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh,
TextButton( "Scan"
onClick = { )
onDeleteTag(tag.tagId)
showDeleteConfirm = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}, },
dismissButton = { text = { Text(if (showMenu) "Close" else "Scan Tags") },
TextButton(onClick = { showDeleteConfirm = false }) { containerColor = MaterialTheme.colorScheme.primaryContainer,
Text("Cancel") 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 @Composable
private fun AddTagDialog( private fun AddTagDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -500,18 +610,19 @@ private fun AddTagDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Add New Tag") }, icon = { Icon(Icons.Default.Add, contentDescription = null) },
title = { Text("Add Custom Tag") },
text = { text = {
OutlinedTextField( OutlinedTextField(
value = tagName, value = tagName,
onValueChange = { tagName = it }, onValueChange = { tagName = it },
label = { Text("Tag name") }, label = { Text("Tag Name") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
}, },
confirmButton = { confirmButton = {
TextButton( Button(
onClick = { onConfirm(tagName) }, onClick = { onConfirm(tagName) },
enabled = tagName.isNotBlank() enabled = tagName.isNotBlank()
) { ) {
@@ -525,100 +636,3 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection import com.google.mlkit.vision.face.FaceDetection
import androidx.compose.ui.graphics.Color
import com.google.mlkit.vision.face.FaceDetectorOptions import com.google.mlkit.vision.face.FaceDetectorOptions
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext 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. * REMOVED CLUTTER:
* Face bounds from FaceDetectionHelper are from downsampled images and won't match * - "Preview (tap to select)" header
* the full resolution bitmap loaded here. * - "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 @Composable
fun FacePickerDialog( fun FacePickerDialog(
@@ -107,9 +113,9 @@ fun FacePickerDialog(
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.94f) .fillMaxWidth(0.92f)
.wrapContentHeight(), .wrapContentHeight(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) )
@@ -117,175 +123,70 @@ fun FacePickerDialog(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Header // Minimal header - just close button
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column {
Text( Text(
text = "Pick a Face", text = "${result.faceCount} faces",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text(
text = "${result.faceCount} faces detected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDismiss) { IconButton(onClick = onDismiss) {
Icon( Icon(Icons.Default.Close, contentDescription = "Close")
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) { if (isLoading) {
// Loading state // Loading state - minimal
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp), .height(180.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
Text(
"Processing faces...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} else if (errorMessage != null) { } else if (errorMessage != null) {
// Error state // Error state - minimal
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(
text = errorMessage ?: "Unknown error", text = errorMessage!!,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.onErrorContainer style = MaterialTheme.typography.bodyMedium
) )
}
}
} else { } else {
// Original image preview // CLEAN face grid - NO labels, NO text
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
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
croppedFaces.forEachIndexed { index, faceBitmap -> croppedFaces.forEachIndexed { index, faceBitmap ->
FacePreviewCard( CleanFaceCard(
faceBitmap = faceBitmap, faceBitmap = faceBitmap,
index = index + 1,
isSelected = selectedFaceIndex == index, isSelected = selectedFaceIndex == index,
onClick = { selectedFaceIndex = index }, onClick = { selectedFaceIndex = index },
modifier = Modifier.weight(1f) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
OutlinedButton( TextButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier = Modifier.weight(1f)
.weight(1f)
.height(52.dp),
shape = RoundedCornerShape(14.dp)
) { ) {
Text("Cancel", style = MaterialTheme.typography.titleMedium) Text("Skip")
} }
Button( Button(
@@ -294,19 +195,16 @@ fun FacePickerDialog(
onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex]) onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex])
} }
}, },
modifier = Modifier enabled = !isLoading && croppedFaces.isNotEmpty(),
.weight(1f) modifier = Modifier.weight(1f)
.height(52.dp),
enabled = !isLoading && croppedFaces.isNotEmpty() && errorMessage == null,
shape = RoundedCornerShape(14.dp)
) { ) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.Check,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) modifier = Modifier.size(18.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(6.dp))
Text("Use This Face", style = MaterialTheme.typography.titleMedium) Text("Use")
} }
} }
} }
@@ -315,63 +213,53 @@ 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 @Composable
private fun FacePreviewCard( private fun CleanFaceCard(
faceBitmap: Bitmap, faceBitmap: Bitmap,
index: Int,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card( Card(
modifier = modifier modifier = modifier
.aspectRatio(0.75f) .aspectRatio(1f) // SQUARE = bigger previews!
.clickable(onClick = onClick), .clickable(onClick = onClick),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (isSelected) containerColor = MaterialTheme.colorScheme.surfaceVariant
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
), ),
border = if (isSelected) border = if (isSelected)
BorderStroke(3.dp, MaterialTheme.colorScheme.primary) BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
else else
BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation( elevation = CardDefaults.cardElevation(
defaultElevation = if (isSelected) 8.dp else 2.dp defaultElevation = if (isSelected) 4.dp else 1.dp
) )
) { ) {
Column( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), // Face image - FULL SIZE
horizontalAlignment = Alignment.CenterHorizontally
) {
// Face image
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Image( Image(
bitmap = faceBitmap.asImageBitmap(), bitmap = faceBitmap.asImageBitmap(),
contentDescription = "Face $index", contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
// Selected overlay with checkmark // Checkmark in corner - ONLY if selected
if (isSelected) { if (isSelected) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) .align(Alignment.TopEnd)
) { .padding(6.dp)
Box( .size(32.dp),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp shadowElevation = 4.dp
@@ -380,39 +268,14 @@ private fun FacePreviewCard(
Icons.Default.CheckCircle, Icons.Default.CheckCircle,
contentDescription = "Selected", contentDescription = "Selected",
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(6.dp)
.size(40.dp), .size(20.dp),
tint = MaterialTheme.colorScheme.onPrimary 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 uri: Uri
): Bitmap? = withContext(Dispatchers.IO) { ): Bitmap? = withContext(Dispatchers.IO) {
try { try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri) val inputStream = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also { BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close() inputStream?.close()
} }
@@ -437,19 +300,18 @@ private suspend fun loadFullResolutionBitmap(
*/ */
private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List<Rect> = withContext(Dispatchers.Default) { private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List<Rect> = withContext(Dispatchers.Default) {
try { try {
val faceDetectorOptions = FaceDetectorOptions.Builder() val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.15f) .setMinFaceSize(0.10f)
.build() .build()
val detector = FaceDetection.getClient(faceDetectorOptions) val detector = FaceDetection.getClient(options)
val inputImage = InputImage.fromBitmap(bitmap, 0) val image = InputImage.fromBitmap(bitmap, 0)
val faces = detector.process(image).await()
val faces = detector.process(inputImage).await() // Sort by size (largest first)
// Sort by face size (largest first)
faces.sortedByDescending { face -> faces.sortedByDescending { face ->
face.boundingBox.width() * face.boundingBox.height() face.boundingBox.width() * face.boundingBox.height()
}.map { it.boundingBox } }.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 { private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
// Add 20% padding around the face // 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 // Face Picker Dialog
showFacePickerDialog?.let { result -> showFacePickerDialog?.let { result ->
ImprovedFacePickerDialog( // CHANGED FacePickerDialog ( // CHANGED
result = result, result = result,
onDismiss = { showFacePickerDialog = null }, onDismiss = { showFacePickerDialog = null },
onFaceSelected = { faceIndex, croppedFaceBitmap -> onFaceSelected = { faceIndex, croppedFaceBitmap ->

View File

@@ -13,50 +13,38 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import 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: * Features:
* - Name input * - Person info capture (name, DOB, relationship)
* - Date of birth picker
* - Relationship selector
* - Onboarding cards * - Onboarding cards
* - Beautiful gradient design * - Beautiful gradient design
* - Clear call to action * - Clear call to action
* - Scrollable on small screens
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TrainingScreen( fun TrainingScreen(
onSelectImages: () -> Unit, onSelectImages: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trainViewModel: TrainViewModel = hiltViewModel() trainViewModel: TrainViewModel = hiltViewModel()
) { ) {
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Train New Person") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(20.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp) verticalArrangement = Arrangement.spacedBy(20.dp)
@@ -99,11 +87,10 @@ fun TrainingScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
}
// Person info dialog // Person info dialog
if (showInfoDialog) { if (showInfoDialog) {
BeautifulPersonInfoDialog( // CHANGED BeautifulPersonInfoDialog(
onDismiss = { showInfoDialog = false }, onDismiss = { showInfoDialog = false },
onConfirm = { name, dob, relationship -> onConfirm = { name, dob, relationship ->
showInfoDialog = false showInfoDialog = false
@@ -200,16 +187,16 @@ private fun HowItWorksSection() {
StepCard( StepCard(
number = 3, number = 3,
icon = Icons.Default.ModelTraining, icon = Icons.Default.SmartToy,
title = "AI Learns Their Face", title = "AI Training",
description = "Takes ~30 seconds to train" description = "We'll create a recognition model"
) )
StepCard( StepCard(
number = 4, number = 4,
icon = Icons.Default.Search, icon = Icons.Default.AutoFixHigh,
title = "Auto-Tag Your Library", title = "Auto-Tag Photos",
description = "Find them in all your photos" description = "Find this person across your library"
) )
} }
} }
@@ -217,31 +204,31 @@ private fun HowItWorksSection() {
@Composable @Composable
private fun StepCard( private fun StepCard(
number: Int, number: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: ImageVector,
title: String, title: String,
description: String description: String
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(16.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Number badge // Number circle
Surface( Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary
modifier = Modifier.size(48.dp)
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Text(
text = number.toString(), "$number",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
@@ -249,6 +236,7 @@ private fun StepCard(
} }
} }
// Content
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -266,7 +254,6 @@ private fun StepCard(
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
} }
Spacer(Modifier.height(4.dp))
Text( Text(
description, description,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -282,7 +269,7 @@ private fun RequirementsCard() {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
), ),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
) { ) {
@@ -297,225 +284,59 @@ private fun RequirementsCard() {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
) )
Text( Text(
"What You'll Need", "Best Results",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
RequirementItem("20-30 photos of the person", true) RequirementItem(
RequirementItem("Different angles and lighting", true) icon = Icons.Default.PhotoCamera,
RequirementItem("Clear face visibility", true) text = "20-30 photos minimum"
RequirementItem("Mix of expressions", true) )
RequirementItem("2-3 minutes of your time", true)
RequirementItem(
icon = Icons.Default.Face,
text = "Clear, well-lit face photos"
)
RequirementItem(
icon = Icons.Default.Diversity1,
text = "Variety of angles & expressions"
)
RequirementItem(
icon = Icons.Default.HighQuality,
text = "Good quality images"
)
} }
} }
} }
@Composable @Composable
private fun RequirementItem(text: String, isMet: Boolean) { private fun RequirementItem(
Row( icon: ImageVector,
horizontalArrangement = Arrangement.spacedBy(8.dp), text: String
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
)
}
}
@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) }
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( Row(
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) verticalAlignment = Alignment.CenterVertically,
) { modifier = Modifier.padding(vertical = 4.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( Icon(
Icons.Default.Lock, icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
"All data stays on your device", text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSecondaryContainer
) )
} }
}
}
},
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)
)
}
}
}
private fun formatDate(timestamp: Long): String {
val formatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return formatter.format(Date(timestamp))
} }

View File

@@ -17,46 +17,35 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.utilities.stats.StatsScreen 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: * Features:
* - Stats tab (photo statistics and analytics) * - Stats tab (photo statistics and analytics)
* - Tools tab (scan, duplicates, bursts, quality) * - Tools tab (scan, duplicates, bursts, quality)
* - Clean TabRow navigation
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PhotoUtilitiesScreen( fun PhotoUtilitiesScreen(
viewModel: PhotoUtilitiesViewModel = hiltViewModel() viewModel: PhotoUtilitiesViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle() val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle()
var selectedTab by remember { mutableStateOf(0) } var selectedTab by remember { mutableStateOf(0) }
Scaffold( Column(modifier = modifier.fillMaxSize()) {
topBar = { // TabRow for Stats/Tools
Column { TabRow(
TopAppBar( selectedTabIndex = selectedTab,
title = { containerColor = MaterialTheme.colorScheme.surface,
Column { contentColor = MaterialTheme.colorScheme.primary
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( Tab(
selected = selectedTab == 0, selected = selectedTab == 0,
onClick = { selectedTab = 0 }, onClick = { selectedTab = 0 },
@@ -70,24 +59,22 @@ fun PhotoUtilitiesScreen(
icon = { Icon(Icons.Default.Build, "Tools") } icon = { Icon(Icons.Default.Build, "Tools") }
) )
} }
}
} // Tab content
) { paddingValues ->
when (selectedTab) { when (selectedTab) {
0 -> { 0 -> {
// Stats tab - delegate to StatsScreen // Stats tab
StatsScreen() StatsScreen()
} }
1 -> { 1 -> {
// Tools tab - existing utilities // Tools tab
ToolsTabContent( ToolsTabContent(
uiState = uiState, uiState = uiState,
scanProgress = scanProgress, scanProgress = scanProgress,
onScanPhotos = { viewModel.scanForPhotos() }, onScanPhotos = { viewModel.scanForPhotos() },
onDetectDuplicates = { viewModel.detectDuplicates() }, onDetectDuplicates = { viewModel.detectDuplicates() },
onDetectBursts = { viewModel.detectBursts() }, onDetectBursts = { viewModel.detectBursts() },
onAnalyzeQuality = { viewModel.analyzeQuality() }, onAnalyzeQuality = { viewModel.analyzeQuality() }
modifier = Modifier.padding(paddingValues)
) )
} }
} }
@@ -257,13 +244,13 @@ private fun SectionHeader(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 4.dp)
) { ) {
Icon( Icon(
icon, icon,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) modifier = Modifier.size(20.dp)
) )
Text( Text(
text = title, text = title,
@@ -285,36 +272,35 @@ private fun UtilityCard(
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), 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)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Row( Row(
modifier = Modifier horizontalArrangement = Arrangement.spacedBy(12.dp),
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Icon
Surface( Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer
modifier = Modifier.size(56.dp)
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
icon, icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = MaterialTheme.colorScheme.primary modifier = Modifier.size(24.dp)
) )
} }
} }
// Text Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -326,11 +312,13 @@ private fun UtilityCard(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
// Button
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled modifier = Modifier.fillMaxWidth(),
enabled = enabled,
shape = RoundedCornerShape(12.dp)
) { ) {
Text(buttonText) Text(buttonText)
} }
@@ -343,43 +331,34 @@ private fun ProgressCard(progress: ScanProgress) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
) )
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(16.dp),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(8.dp)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = progress.message, text = progress.message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
if (progress.total > 0) {
Text( Text(
text = "${progress.current} / ${progress.total}", text = "${progress.current}/${progress.total}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
if (progress.total > 0) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { progress.current.toFloat() / progress.total.toFloat() }, progress = { progress.current.toFloat() / progress.total.toFloat() },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
} }
} }
} }
@@ -393,15 +372,11 @@ private fun ResultCard(
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
containerColor = iconTint.copy(alpha = 0.1f)
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.padding(16.dp),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(12.dp),
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@@ -410,9 +385,7 @@ private fun ResultCard(
tint = iconTint, tint = iconTint,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
Column( Column {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -420,7 +393,8 @@ private fun ResultCard(
) )
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -432,62 +406,25 @@ private fun InfoCard() {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( 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( Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.Default.Info, Icons.Default.Info,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(20.dp)
) )
Text( Text(
text = "How It Works", text = "These tools help you organize and maintain your photo collection",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
InfoItem(
"Duplicates",
"Finds exact duplicates by comparing file content"
)
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"
)
}
}
}
@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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSecondaryContainer
modifier = Modifier.padding(start = 12.dp)
) )
} }
}
} }