diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/model/ImageWithEverything.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/model/ImageWithEverything.kt index 2a81fde..cb6158f 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/model/ImageWithEverything.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/model/ImageWithEverything.kt @@ -1,23 +1,46 @@ package com.placeholder.sherpai2.data.local.model import androidx.room.Embedded +import androidx.room.Junction import androidx.room.Relation import com.placeholder.sherpai2.data.local.entity.* +/** + * ImageWithEverything - Fully hydrated image with ALL relationships + * + * Room loads this in ONE query using @Transaction! + * NO N+1 problem - all tags and face tags loaded together + * + * Usage: + * - ImageAggregateDao.observeAllImagesWithEverything() + * - Search, Explore, Albums + */ data class ImageWithEverything( - @Embedded val image: ImageEntity, + /** + * Tags for this image (via image_tags join table) + * Room automatically joins through ImageTagEntity + */ @Relation( parentColumn = "imageId", - entityColumn = "imageId" + entityColumn = "tagId", + associateBy = Junction( + value = ImageTagEntity::class, + parentColumn = "imageId", + entityColumn = "tagId" + ) ) - val tags: List, + val tags: List, + /** + * Face tags for this image + * Room automatically loads all PhotoFaceTagEntity for this imageId + */ @Relation( parentColumn = "imageId", entityColumn = "imageId" ) - val events: List -) + val faceTags: List +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt index 21895ed..e942d4c 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt @@ -12,7 +12,6 @@ import com.placeholder.sherpai2.data.local.entity.PersonEntity import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.ui.search.DateRange -import com.placeholder.sherpai2.ui.search.DisplayMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -25,8 +24,8 @@ import javax.inject.Inject * Features: * - Search within album * - Date filtering - * - Simple/Verbose toggle * - Album stats + * - Export functionality */ @HiltViewModel class AlbumViewModel @Inject constructor( @@ -54,10 +53,6 @@ class AlbumViewModel @Inject constructor( private val _dateRange = MutableStateFlow(DateRange.ALL_TIME) val dateRange: StateFlow = _dateRange.asStateFlow() - // Display mode - private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE) - val displayMode: StateFlow = _displayMode.asStateFlow() - init { loadAlbumData() } @@ -93,7 +88,7 @@ class AlbumViewModel @Inject constructor( combine( _searchQuery, _dateRange - ) { query, dateRange -> + ) { query: String, dateRange: DateRange -> Pair(query, dateRange) }.collectLatest { (query, dateRange) -> val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f) @@ -119,7 +114,7 @@ class AlbumViewModel @Inject constructor( .distinctBy { it.id } _uiState.value = AlbumUiState.Success( - albumName = tag.value.replace("_", " ").capitalize(), + albumName = tag.value.replace("_", " ").replaceFirstChar { it.uppercase() }, albumType = "Tag", photos = imagesWithFaces, personCount = uniquePersons.size, @@ -138,7 +133,7 @@ class AlbumViewModel @Inject constructor( combine( _searchQuery, _dateRange - ) { query, dateRange -> + ) { query: String, dateRange: DateRange -> Pair(query, dateRange) }.collectLatest { (query, dateRange) -> val images = faceRecognitionRepository.getImagesForPerson(albumId) @@ -184,7 +179,7 @@ class AlbumViewModel @Inject constructor( combine( _searchQuery, _dateRange - ) { query, _ -> + ) { query: String, _: DateRange -> query }.collectLatest { query -> val images = imageDao.getImagesInRange(startTime, endTime) @@ -224,13 +219,6 @@ class AlbumViewModel @Inject constructor( _dateRange.value = range } - fun toggleDisplayMode() { - _displayMode.value = when (_displayMode.value) { - DisplayMode.SIMPLE -> DisplayMode.VERBOSE - DisplayMode.VERBOSE -> DisplayMode.SIMPLE - } - } - private fun isInDateRange(timestamp: Long, range: DateRange): Boolean { return when (range) { DateRange.ALL_TIME -> true @@ -311,10 +299,6 @@ class AlbumViewModel @Inject constructor( set(Calendar.MILLISECOND, 0) }.timeInMillis } - - private fun String.capitalize(): String { - return this.replaceFirstChar { it.uppercase() } - } } sealed class AlbumUiState { diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt index 8aedfd2..9866b09 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt @@ -1,11 +1,9 @@ package com.placeholder.sherpai2.ui.album -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.* -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -13,23 +11,24 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import com.placeholder.sherpai2.ui.search.DateRange -import com.placeholder.sherpai2.ui.search.DisplayMode -import com.placeholder.sherpai2.ui.search.components.ImageGridItem /** - * AlbumViewScreen - UPDATED with clickable images + * AlbumViewScreen - CLEAN VERSION with Export * - * Changes: - * - PhotoCard now clickable - * - Passes onImageClick to ImageGridItem - * - Entire card surface clickable as backup + * REMOVED: + * - DisplayMode toggle + * - Verbose person tags + * + * ADDED: + * - Export menu (Folder, Zip, Collage) + * - Clean simple layout */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -41,7 +40,8 @@ fun AlbumViewScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val dateRange by viewModel.dateRange.collectAsStateWithLifecycle() - val displayMode by viewModel.displayMode.collectAsStateWithLifecycle() + + var showExportMenu by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -73,15 +73,9 @@ fun AlbumViewScreen( } }, actions = { - IconButton(onClick = { viewModel.toggleDisplayMode() }) { - Icon( - imageVector = if (displayMode == DisplayMode.SIMPLE) { - Icons.Default.ViewList - } else { - Icons.Default.ViewModule - }, - contentDescription = "Toggle view" - ) + // Export button + IconButton(onClick = { showExportMenu = true }) { + Icon(Icons.Default.FileDownload, "Export") } } ) @@ -127,7 +121,6 @@ fun AlbumViewScreen( state = state, searchQuery = searchQuery, dateRange = dateRange, - displayMode = displayMode, onSearchChange = { viewModel.setSearchQuery(it) }, onDateRangeChange = { viewModel.setDateRange(it) }, onImageClick = onImageClick, @@ -136,6 +129,33 @@ fun AlbumViewScreen( } } } + + // Export menu dialog + if (showExportMenu) { + ExportDialog( + albumName = when (val state = uiState) { + is AlbumUiState.Success -> state.albumName + else -> "Album" + }, + photoCount = when (val state = uiState) { + is AlbumUiState.Success -> state.photos.size + else -> 0 + }, + onDismiss = { showExportMenu = false }, + onExportToFolder = { + // TODO: Implement folder export + showExportMenu = false + }, + onExportToZip = { + // TODO: Implement zip export + showExportMenu = false + }, + onExportToCollage = { + // TODO: Implement collage export + showExportMenu = false + } + ) + } } @Composable @@ -143,7 +163,6 @@ private fun AlbumContent( state: AlbumUiState.Success, searchQuery: String, dateRange: DateRange, - displayMode: DisplayMode, onSearchChange: (String) -> Unit, onDateRangeChange: (DateRange) -> Unit, onImageClick: (String) -> Unit, @@ -206,7 +225,8 @@ private fun AlbumContent( .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - items(DateRange.entries) { range -> + items(DateRange.entries.size) { index -> + val range = DateRange.entries[index] val isActive = dateRange == range FilterChip( selected = isActive, @@ -244,7 +264,6 @@ private fun AlbumContent( ) { photo -> PhotoCard( photo = photo, - displayMode = displayMode, onImageClick = onImageClick ) } @@ -254,7 +273,11 @@ private fun AlbumContent( } @Composable -private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) { +private fun StatItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String +) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) @@ -279,86 +302,180 @@ private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, labe } /** - * PhotoCard - UPDATED: Now fully clickable + * PhotoCard - CLEAN VERSION: Simple image + person names */ @Composable private fun PhotoCard( photo: AlbumPhoto, - displayMode: DisplayMode, onImageClick: (String) -> Unit ) { Card( modifier = Modifier .fillMaxWidth() - .clickable { onImageClick(photo.image.imageUri) }, // ✅ ENTIRE CARD CLICKABLE + .aspectRatio(1f) + .clickable { onImageClick(photo.image.imageUri) }, shape = RoundedCornerShape(12.dp) ) { - Column { - // Image (also clickable via ImageGridItem) - ImageGridItem( - image = photo.image, - onClick = { onImageClick(photo.image.imageUri) } // ✅ IMAGE CLICKABLE + Box { + // Image + AsyncImage( + model = photo.image.imageUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = androidx.compose.ui.layout.ContentScale.Crop ) - // Person tags + // Person names overlay (if any) if (photo.persons.isNotEmpty()) { - when (displayMode) { - DisplayMode.SIMPLE -> { - Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = photo.persons.take(3).joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - DisplayMode.VERBOSE -> { - Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - photo.persons.take(3).forEachIndexed { index, person -> - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Face, - null, - Modifier.size(14.dp), - MaterialTheme.colorScheme.primary - ) - Text( - text = person.name, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (index < photo.faceTags.size) { - val confidence = (photo.faceTags[index].confidence * 100).toInt() - Text( - text = "$confidence%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - } - } - } + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + Text( + text = photo.persons.take(2).joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium + ) } } } } +} + +/** + * Export Dialog + */ +@Composable +private fun ExportDialog( + albumName: String, + photoCount: Int, + onDismiss: () -> Unit, + onExportToFolder: () -> Unit, + onExportToZip: () -> Unit, + onExportToCollage: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.FileDownload, null) }, + title = { Text("Export Album") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "$photoCount photos from \"$albumName\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Export to Folder + ExportOption( + icon = Icons.Default.Folder, + title = "Export to Folder", + description = "Save all photos to a folder", + onClick = onExportToFolder + ) + + // Export to Zip + ExportOption( + icon = Icons.Default.FolderZip, + title = "Export as ZIP", + description = "Create a compressed archive", + onClick = onExportToZip + ) + + // Export to Collage (placeholder) + ExportOption( + icon = Icons.Default.GridView, + title = "Create Collage", + description = "Coming soon!", + onClick = onExportToCollage, + enabled = false + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun ExportOption( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + description: String, + onClick: () -> Unit, + enabled: Boolean = true +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = if (enabled) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primary.copy( + alpha = if (enabled) 1f else 0.5f + ), + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + ) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) 1f else 0.5f + ) + ) + } + + if (enabled) { + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt index 59ced9e..50b1dd0 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt @@ -23,57 +23,28 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel /** - * ExploreScreen - REDESIGNED + * CLEANED ExploreScreen - No gradient header banner + * + * Removed: + * - Gradient header box (lines 46-75) that created banner effect + * - "Explore" title (MainScreen shows it) * * Features: - * - Rectangular album cards (more compact) + * - Rectangular album cards (compact) * - Stories section (recent highlights) * - Clickable navigation to AlbumViewScreen * - Beautiful gradients and icons + * - Mobile-friendly scrolling */ @Composable fun ExploreScreen( onAlbumClick: (albumType: String, albumId: String) -> Unit, - viewModel: ExploreViewModel = hiltViewModel() + viewModel: ExploreViewModel = hiltViewModel(), + modifier: Modifier = Modifier ) { val uiState by viewModel.uiState.collectAsState() - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - ) { - // Header with gradient - Box( - modifier = Modifier - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.surface - ) - ) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = "Explore", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = "Your photo collection organized", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - + Box(modifier = modifier.fillMaxSize()) { when (val state = uiState) { is ExploreViewModel.ExploreUiState.Loading -> { Box( @@ -83,12 +54,18 @@ fun ExploreScreen( CircularProgressIndicator() } } + is ExploreViewModel.ExploreUiState.Success -> { - ExploreContent( - smartAlbums = state.smartAlbums, - onAlbumClick = onAlbumClick - ) + if (state.smartAlbums.isEmpty()) { + EmptyExploreView() + } else { + ExploreContent( + smartAlbums = state.smartAlbums, + onAlbumClick = onAlbumClick + ) + } } + is ExploreViewModel.ExploreUiState.Error -> { Box( modifier = Modifier.fillMaxSize(), @@ -96,17 +73,25 @@ fun ExploreScreen( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(32.dp) ) { Icon( Icons.Default.Error, contentDescription = null, - modifier = Modifier.size(48.dp), + modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error ) + Text( + text = "Error Loading Albums", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) Text( text = state.message, - color = MaterialTheme.colorScheme.error + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) } } @@ -115,6 +100,9 @@ fun ExploreScreen( } } +/** + * Main content - scrollable album sections + */ @Composable private fun ExploreContent( smartAlbums: List, @@ -127,10 +115,13 @@ private fun ExploreContent( ) { // Stories Section (Recent Highlights) item { - StoriesSection( - albums = smartAlbums.filter { it.imageCount > 0 }.take(10), - onAlbumClick = onAlbumClick - ) + val storyAlbums = smartAlbums.filter { it.imageCount > 0 }.take(10) + if (storyAlbums.isNotEmpty()) { + StoriesSection( + albums = storyAlbums, + onAlbumClick = onAlbumClick + ) + } } // Time-based Albums @@ -225,7 +216,7 @@ private fun ExploreContent( } /** - * Stories section - Instagram-style circular highlights + * Stories section - circular album previews */ @Composable private fun StoriesSection( @@ -294,7 +285,8 @@ private fun StoryCircle( style = MaterialTheme.typography.labelSmall, maxLines = 2, modifier = Modifier.width(80.dp), - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) Text( @@ -342,7 +334,7 @@ private fun AlbumSection( } /** - * Rectangular album card - more compact than square + * Rectangular album card - compact design */ @Composable private fun AlbumCard( @@ -398,6 +390,44 @@ private fun AlbumCard( } } +/** + * Empty state + */ +@Composable +private fun EmptyExploreView() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.PhotoAlbum, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Text( + "No Albums Yet", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Text( + "Add photos to your collection to see smart albums", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } +} + /** * Get navigation parameters for album */ diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt index 0668180..e4833e0 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt @@ -14,24 +14,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import java.text.SimpleDateFormat -import java.util.* /** - * PersonInventoryScreen - Manage trained face models + * CLEANED PersonInventoryScreen - No duplicate header * - * Features: - * - List all trained persons - * - View stats - * - DELETE models - * - SCAN LIBRARY to find person in all photos (NEW!) + * Removed: + * - Scaffold wrapper + * - TopAppBar (was creating banner) + * - "Trained People" title (MainScreen shows it) + * + * FIXED to match ViewModel exactly: + * - Uses InventoryUiState.Success with persons + * - Uses stats.taggedPhotoCount (not photoCount) + * - Passes both personId AND faceModelId to methods */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PersonInventoryScreen( modifier: Modifier = Modifier, @@ -44,316 +43,171 @@ fun PersonInventoryScreen( var personToDelete by remember { mutableStateOf(null) } var personToScan by remember { mutableStateOf(null) } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Trained People") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ), - actions = { - IconButton(onClick = { viewModel.loadPersons() }) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - ) - } - ) { paddingValues -> - Box( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) - ) { - when (val state = uiState) { - is PersonInventoryViewModel.InventoryUiState.Loading -> { - LoadingView() - } - - is PersonInventoryViewModel.InventoryUiState.Success -> { - if (state.persons.isEmpty()) { - EmptyView() - } else { - PersonListView( - persons = state.persons, - onDeleteClick = { personToDelete = it }, - onScanClick = { personToScan = it }, - onViewPhotos = { onViewPersonPhotos(it.person.id) }, - scanningState = scanningState - ) - } - } - - is PersonInventoryViewModel.InventoryUiState.Error -> { - ErrorView( - message = state.message, - onRetry = { viewModel.loadPersons() } - ) - } - } - - // Scanning overlay - if (scanningState is PersonInventoryViewModel.ScanningState.Scanning) { - ScanningOverlay(scanningState as PersonInventoryViewModel.ScanningState.Scanning) - } - } - } - - // Delete confirmation dialog - personToDelete?.let { personWithStats -> - AlertDialog( - onDismissRequest = { personToDelete = null }, - title = { Text("Delete ${personWithStats.person.name}?") }, - text = { - Text( - "This will delete the face model and all ${personWithStats.stats.taggedPhotoCount} " + - "face tags. Your photos will NOT be deleted." - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.deletePerson( - personWithStats.person.id, - personWithStats.stats.faceModelId - ) - personToDelete = null - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text("Delete") - } - }, - dismissButton = { - TextButton(onClick = { personToDelete = null }) { - Text("Cancel") - } - } - ) - } - - // Scan library confirmation dialog - personToScan?.let { personWithStats -> - AlertDialog( - onDismissRequest = { personToScan = null }, - icon = { Icon(Icons.Default.Search, contentDescription = null) }, - title = { Text("Scan Library for ${personWithStats.person.name}?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - "This will scan your entire photo library and automatically tag " + - "all photos containing ${personWithStats.person.name}." - ) - Text( - "Currently tagged: ${personWithStats.stats.taggedPhotoCount} photos", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - confirmButton = { - Button( - onClick = { - viewModel.scanLibraryForPerson( - personWithStats.person.id, - personWithStats.stats.faceModelId - ) - personToScan = null - } - ) { - Icon(Icons.Default.Search, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Start Scan") - } - }, - dismissButton = { - TextButton(onClick = { personToScan = null }) { - Text("Cancel") - } - } - ) - } -} - -@Composable -private fun LoadingView() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text( - text = "Loading trained models...", - style = MaterialTheme.typography.bodyMedium - ) - } - } -} - -@Composable -private fun EmptyView() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(32.dp) - ) { - Icon( - Icons.Default.Face, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - ) - Text( - text = "No trained people yet", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = "Train a person using 10+ photos to start recognizing faces", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun ErrorView( - message: String, - onRetry: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(32.dp) - ) { - Icon( - Icons.Default.Warning, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.error - ) - Text( - text = "Error", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Button(onClick = onRetry) { - Icon(Icons.Default.Refresh, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Retry") - } - } - } -} - -@Composable -private fun PersonListView( - persons: List, - onDeleteClick: (PersonInventoryViewModel.PersonWithStats) -> Unit, - onScanClick: (PersonInventoryViewModel.PersonWithStats) -> Unit, - onViewPhotos: (PersonInventoryViewModel.PersonWithStats) -> Unit, - scanningState: PersonInventoryViewModel.ScanningState -) { LazyColumn( + modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Summary card - item { - SummaryCard(totalPersons = persons.size) - Spacer(modifier = Modifier.height(8.dp)) - } + when (val state = uiState) { + is PersonInventoryViewModel.InventoryUiState.Loading -> { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } - // Person cards - items(persons) { personWithStats -> - PersonCard( - personWithStats = personWithStats, - onDeleteClick = { onDeleteClick(personWithStats) }, - onScanClick = { onScanClick(personWithStats) }, - onViewPhotos = { onViewPhotos(personWithStats) }, - isScanning = scanningState is PersonInventoryViewModel.ScanningState.Scanning && - scanningState.personId == personWithStats.person.id - ) + is PersonInventoryViewModel.InventoryUiState.Success -> { + // Summary card + item { + SummaryCard( + peopleCount = state.persons.size, + totalPhotos = state.persons.sumOf { it.stats.taggedPhotoCount } + ) + } + + // Scanning progress + val currentScanningState = scanningState + if (currentScanningState is PersonInventoryViewModel.ScanningState.Scanning) { + item { + ScanningProgressCard(currentScanningState) + } + } + + // Person list + if (state.persons.isEmpty()) { + item { + EmptyState() + } + } else { + items(state.persons) { person -> + PersonCard( + person = person, + onDelete = { personToDelete = person }, + onScan = { personToScan = person }, + onViewPhotos = { onViewPersonPhotos(person.person.id) } + ) + } + } + } + + is PersonInventoryViewModel.InventoryUiState.Error -> { + item { + ErrorCard(message = state.message) + } + } } } + + // Delete confirmation + personToDelete?.let { person -> + DeleteDialog( + person = person, + onDismiss = { personToDelete = null }, + onConfirm = { + viewModel.deletePerson(person.person.id, person.stats.faceModelId) + personToDelete = null + } + ) + } + + // Scan confirmation + personToScan?.let { person -> + ScanDialog( + person = person, + onDismiss = { personToScan = null }, + onConfirm = { + viewModel.scanLibraryForPerson(person.person.id, person.stats.faceModelId) + personToScan = null + } + ) + } } +/** + * Summary card with stats + */ @Composable -private fun SummaryCard(totalPersons: Int) { +private fun SummaryCard(peopleCount: Int, totalPhotos: Int) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) ) ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.SpaceEvenly ) { - Icon( - Icons.Default.Face, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary + StatItem( + icon = Icons.Default.People, + value = peopleCount.toString(), + label = "People" + ) + VerticalDivider( + modifier = Modifier.height(56.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + StatItem( + icon = Icons.Default.PhotoLibrary, + value = totalPhotos.toString(), + label = "Tagged" ) - Column { - Text( - text = "$totalPersons trained ${if (totalPersons == 1) "person" else "people"}", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = "Face recognition models ready", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - ) - } } } } @Composable -private fun PersonCard( - personWithStats: PersonInventoryViewModel.PersonWithStats, - onDeleteClick: () -> Unit, - onScanClick: () -> Unit, - onViewPhotos: () -> Unit, - isScanning: Boolean -) { - val stats = personWithStats.stats +private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, label: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +/** + * Person card with stats and actions + */ +@Composable +private fun PersonCard( + person: PersonInventoryViewModel.PersonWithStats, + onDelete: () -> Unit, + onScan: () -> Unit, + onViewPhotos: () -> Unit +) { Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Header: Name and actions + // Header row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -363,38 +217,39 @@ private fun PersonCard( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center + // Avatar + Surface( + modifier = Modifier.size(48.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer ) { - Text( - text = personWithStats.person.name.take(1).uppercase(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary - ) + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } } + // Name and stats Column { Text( - text = personWithStats.person.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = person.person.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) Text( - text = "ID: ${personWithStats.person.id.take(8)}", + text = "${person.stats.taggedPhotoCount} photos • ${person.stats.trainingImageCount} trained", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - IconButton(onClick = onDeleteClick) { + // Delete button + IconButton(onClick = onDelete) { Icon( Icons.Default.Delete, contentDescription = "Delete", @@ -403,212 +258,251 @@ private fun PersonCard( } } - Spacer(modifier = Modifier.height(16.dp)) - - // Stats grid - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - StatItem( - icon = Icons.Default.PhotoCamera, - label = "Training", - value = "${stats.trainingImageCount}" - ) - StatItem( - icon = Icons.Default.AccountBox, - label = "Tagged", - value = "${stats.taggedPhotoCount}" - ) - StatItem( - icon = Icons.Default.CheckCircle, - label = "Confidence", - value = "${(stats.averageConfidence * 100).toInt()}%", - valueColor = if (stats.averageConfidence >= 0.8f) { - MaterialTheme.colorScheme.primary - } else if (stats.averageConfidence >= 0.6f) { - MaterialTheme.colorScheme.tertiary - } else { - MaterialTheme.colorScheme.error - } - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Last detected - stats.lastDetectedAt?.let { timestamp -> - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.DateRange, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "Last detected: ${formatDate(timestamp)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Action buttons row + // Action buttons Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - // Scan Library button (PRIMARY ACTION) - Button( - onClick = onScanClick, - modifier = Modifier.weight(1f), - enabled = !isScanning, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) + OutlinedButton( + onClick = onScan, + modifier = Modifier.weight(1f) ) { - if (isScanning) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) - } else { - Icon( - Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Text(if (isScanning) "Scanning..." else "Scan Library") + Icon( + Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Scan") } - // View photos button - if (stats.taggedPhotoCount > 0) { - OutlinedButton( - onClick = onViewPhotos, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.Default.Photo, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("View (${stats.taggedPhotoCount})") - } + Button( + onClick = onViewPhotos, + modifier = Modifier.weight(1f) + ) { + Icon( + Icons.Default.PhotoLibrary, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("View") } } } } } -@Composable -private fun StatItem( - icon: ImageVector, - label: String, - value: String, - valueColor: Color = MaterialTheme.colorScheme.primary -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = value, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = valueColor - ) - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - /** - * Scanning overlay showing progress + * Scanning progress card */ @Composable -private fun ScanningOverlay(state: PersonInventoryViewModel.ScanningState.Scanning) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)), - contentAlignment = Alignment.Center +private fun ScanningProgressCard(scanningState: PersonInventoryViewModel.ScanningState.Scanning) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f) + ) ) { - Card( - modifier = Modifier - .fillMaxWidth(0.85f) - .padding(24.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = "Scanning Library", - style = MaterialTheme.typography.titleLarge, + "Scanning for ${scanningState.personName}", + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) - Text( - text = "Finding ${state.personName} in your photos...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - LinearProgressIndicator( - progress = { state.progress / state.total.toFloat() }, - modifier = Modifier.fillMaxWidth(), - ) - - Text( - text = "${state.progress} / ${state.total} photos scanned", + "${scanningState.progress}/${scanningState.total}", style = MaterialTheme.typography.bodySmall ) + } + LinearProgressIndicator( + progress = { + if (scanningState.total > 0) { + scanningState.progress.toFloat() / scanningState.total.toFloat() + } else { + 0f + } + }, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = "${state.facesFound} faces detected", - style = MaterialTheme.typography.labelMedium, + "Matches found: ${scanningState.facesFound}", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) + Text( + "Faces: ${scanningState.facesDetected}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } } -private fun formatDate(timestamp: Long): String { - val formatter = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) - return formatter.format(Date(timestamp)) +/** + * Empty state + */ +@Composable +private fun EmptyState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.PersonOff, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Text( + "No People Trained", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Text( + "Train face recognition to find people in your photos", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } +} + +/** + * Error card + */ +@Composable +private fun ErrorCard(message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +/** + * Delete confirmation dialog + */ +@Composable +private fun DeleteDialog( + person: PersonInventoryViewModel.PersonWithStats, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { Text("Delete ${person.person.name}?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("This will permanently delete:") + Text("• Face recognition model", style = MaterialTheme.typography.bodyMedium) + Text("• ${person.stats.taggedPhotoCount} tagged photos will be untagged", style = MaterialTheme.typography.bodyMedium) + Text( + "This action cannot be undone.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +/** + * Scan confirmation dialog + */ +@Composable +private fun ScanDialog( + person: PersonInventoryViewModel.PersonWithStats, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Search, contentDescription = null) }, + title = { Text("Scan for ${person.person.name}?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("This will:") + Text("• Scan all photos in your library", style = MaterialTheme.typography.bodyMedium) + Text("• Detect and tag ${person.person.name}'s face", style = MaterialTheme.typography.bodyMedium) + Text("• May take several minutes", style = MaterialTheme.typography.bodyMedium) + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Start Scan") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt index 1f2e0c6..f9cae35 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt @@ -1,35 +1,34 @@ package com.placeholder.sherpai2.ui.search -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.* import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.placeholder.sherpai2.ui.search.components.ImageGridItem +import coil.compose.AsyncImage /** - * SearchScreen - COMPLETE REDESIGN + * ADVANCED SearchScreen with Boolean Logic * * Features: - * - Near-match search ("low" → "low_res") - * - Quick tag filter chips - * - Date range filtering - * - Clean person-only display - * - Simple/Verbose toggle + * - Include/Exclude people (visual chips) + * - Include/Exclude tags (visual chips) + * - Clear visual distinction (green = include, red = exclude) + * - Real-time filtering + * - OpenSearch-style query building */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,411 +36,507 @@ fun SearchScreen( modifier: Modifier = Modifier, searchViewModel: SearchViewModel, onImageClick: (String) -> Unit, - onAlbumClick: (String) -> Unit = {} // For opening album view + onAlbumClick: ((String) -> Unit)? = null ) { - val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() - val activeTagFilters by searchViewModel.activeTagFilters.collectAsStateWithLifecycle() + val includedPeople by searchViewModel.includedPeople.collectAsStateWithLifecycle() + val excludedPeople by searchViewModel.excludedPeople.collectAsStateWithLifecycle() + val includedTags by searchViewModel.includedTags.collectAsStateWithLifecycle() + val excludedTags by searchViewModel.excludedTags.collectAsStateWithLifecycle() val dateRange by searchViewModel.dateRange.collectAsStateWithLifecycle() - val displayMode by searchViewModel.displayMode.collectAsStateWithLifecycle() - val systemTags by searchViewModel.systemTags.collectAsStateWithLifecycle() + + val availablePeople by searchViewModel.availablePeople.collectAsStateWithLifecycle() + val availableTags by searchViewModel.availableTags.collectAsStateWithLifecycle() val images by searchViewModel .searchImages() .collectAsStateWithLifecycle(initialValue = emptyList()) - Scaffold { paddingValues -> - Column( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) + var showPeoplePicker by remember { mutableStateOf(false) } + var showTagPicker by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxSize()) { + // Search bar + quick add buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Header with gradient - Box( - modifier = Modifier - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.surface - ) - ) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Title - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Column { - Text( - text = "Search Photos", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = "Near-match • Filters • Smart tags", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Simple/Verbose toggle - IconButton( - onClick = { searchViewModel.toggleDisplayMode() } - ) { - Icon( - imageVector = if (displayMode == DisplayMode.SIMPLE) { - Icons.Default.ViewList - } else { - Icons.Default.ViewModule - }, - contentDescription = "Toggle view mode", - tint = MaterialTheme.colorScheme.primary - ) + OutlinedTextField( + value = searchQuery, + onValueChange = { searchViewModel.setSearchQuery(it) }, + placeholder = { Text("Search tags...") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchViewModel.setSearchQuery("") }) { + Icon(Icons.Default.Close, "Clear") } } - - // Search bar - OutlinedTextField( - value = searchQuery, - onValueChange = { searchViewModel.setSearchQuery(it) }, - placeholder = { Text("Search... (e.g., 'low', 'gro', 'nig')") }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchViewModel.setSearchQuery("") }) { - Icon(Icons.Default.Clear, contentDescription = "Clear") - } - } - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp) - ) - } - } - - // Quick Tag Filters - if (systemTags.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Quick Filters", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - if (activeTagFilters.isNotEmpty()) { - TextButton(onClick = { searchViewModel.clearTagFilters() }) { - Text("Clear all") - } - } - } - - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(systemTags) { tag -> - val isActive = tag.value in activeTagFilters - FilterChip( - selected = isActive, - onClick = { searchViewModel.toggleTagFilter(tag.value) }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = getTagEmoji(tag.value), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = tag.value.replace("_", " "), - style = MaterialTheme.typography.bodySmall - ) - } - }, - leadingIcon = if (isActive) { - { Icon(Icons.Default.Check, null, Modifier.size(16.dp)) } - } else null - ) - } - } - } - } - - // Date Range Filters - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(DateRange.entries) { range -> - val isActive = dateRange == range - FilterChip( - selected = isActive, - onClick = { searchViewModel.setDateRange(range) }, - label = { Text(range.displayName) }, - leadingIcon = if (isActive) { - { Icon(Icons.Default.DateRange, null, Modifier.size(16.dp)) } - } else null - ) - } - } - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant + }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(12.dp) ) - // Results - if (images.isEmpty() && searchQuery.isBlank() && activeTagFilters.isEmpty()) { - EmptySearchState() - } else if (images.isEmpty()) { - NoResultsState( - query = searchQuery, - hasFilters = activeTagFilters.isNotEmpty() || dateRange != DateRange.ALL_TIME + // Add person button + IconButton( + onClick = { showPeoplePicker = true }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer ) - } else { - Column { - // Results header + ) { + Icon(Icons.Default.PersonAdd, "Add person filter") + } + + // Add tag button + IconButton( + onClick = { showTagPicker = true }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Icon(Icons.Default.LabelImportant, "Add tag filter") + } + } + + // Active filters display (chips) + if (searchViewModel.hasActiveFilters()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "${images.size} ${if (images.size == 1) "photo" else "photos"}", - style = MaterialTheme.typography.titleMedium, + "Active Filters", + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold ) - - // View Album button (if search results can be grouped) - if (activeTagFilters.size == 1 || searchQuery.isNotBlank()) { - TextButton( - onClick = { - val albumTag = activeTagFilters.firstOrNull() ?: searchQuery - onAlbumClick(albumTag) - } - ) { - Icon( - Icons.Default.Collections, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(4.dp)) - Text("View Album") - } - } - } - - // Photo grid - LazyVerticalGrid( - columns = GridCells.Adaptive(120.dp), - contentPadding = PaddingValues(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize() - ) { - items( - items = images, - key = { it.image.imageId } - ) { imageWithFaceTags -> - PhotoCard( - imageWithFaceTags = imageWithFaceTags, - displayMode = displayMode, - onImageClick = onImageClick - ) - } - } - } - } - } - } -} - -/** - * Photo card with clean person display - */ -@Composable -private fun PhotoCard( - imageWithFaceTags: ImageWithFaceTags, - displayMode: DisplayMode, - onImageClick: (String) -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column { - // Image - ImageGridItem( - image = imageWithFaceTags.image, - onClick = { onImageClick(imageWithFaceTags.image.imageUri) } - ) - - // Person tags (deduplicated) - val uniquePersons = imageWithFaceTags.persons.distinctBy { it.id } - - if (uniquePersons.isNotEmpty()) { - when (displayMode) { - DisplayMode.SIMPLE -> { - // SIMPLE: Just names, no icons, no percentages - Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), - modifier = Modifier.fillMaxWidth() + TextButton( + onClick = { searchViewModel.clearAllFilters() }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) ) { - Text( - text = uniquePersons - .take(3) - .joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text("Clear All", style = MaterialTheme.typography.labelMedium) } } - DisplayMode.VERBOSE -> { - // VERBOSE: Person tags + System tags - Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Person tags with confidence - uniquePersons.take(3).forEachIndexed { index, person -> - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Face, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = person.name, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Find matching face tag for confidence - val matchingTag = imageWithFaceTags.faceTags - .find { tag -> - imageWithFaceTags.persons[imageWithFaceTags.faceTags.indexOf(tag)].id == person.id - } - if (matchingTag != null) { - val confidence = (matchingTag.confidence * 100).toInt() - Text( - text = "$confidence%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - if (uniquePersons.size > 3) { - Text( - text = "+${uniquePersons.size - 3} more", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + // Included People (GREEN) + if (includedPeople.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(includedPeople.toList()) { personId -> + val person = availablePeople.find { it.id == personId } + if (person != null) { + FilterChip( + selected = true, + onClick = { searchViewModel.excludePerson(personId) }, + onLongClick = { searchViewModel.removePersonFilter(personId) }, + label = { Text(person.name) }, + leadingIcon = { + Icon(Icons.Default.Person, null, Modifier.size(16.dp)) + }, + trailingIcon = { + Icon(Icons.Default.Check, null, Modifier.size(16.dp)) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFF4CAF50), // Green + selectedLabelColor = Color.White + ) ) } + } + } + } - // System tags (verbose mode only) - // TODO: Get image tags from ImageWithEverything - // For now, show placeholder - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - // Example system tags - replace with actual tags from image - SystemTagChip("indoor") - SystemTagChip("high_res") - SystemTagChip("morning") + // Excluded People (RED) + if (excludedPeople.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(excludedPeople.toList()) { personId -> + val person = availablePeople.find { it.id == personId } + if (person != null) { + FilterChip( + selected = true, + onClick = { searchViewModel.includePerson(personId) }, + onLongClick = { searchViewModel.removePersonFilter(personId) }, + label = { Text(person.name) }, + leadingIcon = { + Icon(Icons.Default.Person, null, Modifier.size(16.dp)) + }, + trailingIcon = { + Icon(Icons.Default.Close, null, Modifier.size(16.dp)) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFFF44336), // Red + selectedLabelColor = Color.White + ) + ) } } } } + + // Included Tags (GREEN) + if (includedTags.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(includedTags.toList()) { tagValue -> + FilterChip( + selected = true, + onClick = { searchViewModel.excludeTag(tagValue) }, + onLongClick = { searchViewModel.removeTagFilter(tagValue) }, + label = { Text(tagValue) }, + leadingIcon = { + Icon(Icons.Default.Label, null, Modifier.size(16.dp)) + }, + trailingIcon = { + Icon(Icons.Default.Check, null, Modifier.size(16.dp)) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFF4CAF50), + selectedLabelColor = Color.White + ) + ) + } + } + } + + // Excluded Tags (RED) + if (excludedTags.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(excludedTags.toList()) { tagValue -> + FilterChip( + selected = true, + onClick = { searchViewModel.includeTag(tagValue) }, + onLongClick = { searchViewModel.removeTagFilter(tagValue) }, + label = { Text(tagValue) }, + leadingIcon = { + Icon(Icons.Default.Label, null, Modifier.size(16.dp)) + }, + trailingIcon = { + Icon(Icons.Default.Close, null, Modifier.size(16.dp)) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFFF44336), + selectedLabelColor = Color.White + ) + ) + } + } + } + + // Date range + if (dateRange != DateRange.ALL_TIME) { + FilterChip( + selected = true, + onClick = { searchViewModel.setDateRange(DateRange.ALL_TIME) }, + label = { Text(dateRange.displayName) }, + leadingIcon = { + Icon(Icons.Default.DateRange, null, Modifier.size(16.dp)) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) + } + } + } + } + + // Results + if (images.isEmpty() && !searchViewModel.hasActiveFilters()) { + EmptyState() + } else if (images.isEmpty()) { + NoResultsState() + } else { + // Results count + Text( + text = "${images.size} photos • ${searchViewModel.getSearchSummary()}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + // Image grid + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = images, + key = { it.image.imageUri } + ) { imageWithTags -> + Card( + modifier = Modifier + .aspectRatio(1f) + .clickable { onImageClick(imageWithTags.image.imageUri) } + ) { + AsyncImage( + model = imageWithTags.image.imageUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } } } } } -} -@Composable -private fun SystemTagChip(tagValue: String) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) - ) { - Text( - text = tagValue.replace("_", " "), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + // People picker dialog + if (showPeoplePicker) { + PeoplePickerDialog( + people = availablePeople, + includedPeople = includedPeople, + excludedPeople = excludedPeople, + onInclude = { searchViewModel.includePerson(it) }, + onExclude = { searchViewModel.excludePerson(it) }, + onDismiss = { showPeoplePicker = false } + ) + } + + // Tag picker dialog + if (showTagPicker) { + TagPickerDialog( + tags = availableTags, + includedTags = includedTags, + excludedTags = excludedTags, + onInclude = { searchViewModel.includeTag(it) }, + onExclude = { searchViewModel.excludeTag(it) }, + onDismiss = { showTagPicker = false } ) } } @Composable -private fun EmptySearchState() { +private fun FilterChip( + selected: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + label: @Composable () -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + colors: androidx.compose.material3.SelectableChipColors = FilterChipDefaults.filterChipColors() +) { + androidx.compose.material3.FilterChip( + selected = selected, + onClick = onClick, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors + ) +} + +@Composable +private fun PeoplePickerDialog( + people: List, + includedPeople: Set, + excludedPeople: Set, + onInclude: (String) -> Unit, + onExclude: (String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add People Filter") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Tap to INCLUDE (green) • Long press to EXCLUDE (red)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + people.forEach { person -> + val isIncluded = person.id in includedPeople + val isExcluded = person.id in excludedPeople + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onInclude(person.id) }, + colors = CardDefaults.cardColors( + containerColor = when { + isIncluded -> Color(0xFF4CAF50).copy(alpha = 0.3f) + isExcluded -> Color(0xFFF44336).copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(person.name, fontWeight = FontWeight.Medium) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { onInclude(person.id) }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isIncluded) Color(0xFF4CAF50) else Color.Transparent + ) + ) { + Icon(Icons.Default.Check, "Include", tint = if (isIncluded) Color.White else MaterialTheme.colorScheme.onSurface) + } + IconButton( + onClick = { onExclude(person.id) }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isExcluded) Color(0xFFF44336) else Color.Transparent + ) + ) { + Icon(Icons.Default.Close, "Exclude", tint = if (isExcluded) Color.White else MaterialTheme.colorScheme.onSurface) + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + } + ) +} + +@Composable +private fun TagPickerDialog( + tags: List, + includedTags: Set, + excludedTags: Set, + onInclude: (String) -> Unit, + onExclude: (String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Tag Filter") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Tap to INCLUDE (green) • Long press to EXCLUDE (red)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + tags.forEach { tagValue -> + val isIncluded = tagValue in includedTags + val isExcluded = tagValue in excludedTags + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onInclude(tagValue) }, + colors = CardDefaults.cardColors( + containerColor = when { + isIncluded -> Color(0xFF4CAF50).copy(alpha = 0.3f) + isExcluded -> Color(0xFFF44336).copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(tagValue, fontWeight = FontWeight.Medium) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { onInclude(tagValue) }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isIncluded) Color(0xFF4CAF50) else Color.Transparent + ) + ) { + Icon(Icons.Default.Check, "Include", tint = if (isIncluded) Color.White else MaterialTheme.colorScheme.onSurface) + } + IconButton( + onClick = { onExclude(tagValue) }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (isExcluded) Color(0xFFF44336) else Color.Transparent + ) + ) { + Icon(Icons.Default.Close, "Exclude", tint = if (isExcluded) Color.White else MaterialTheme.colorScheme.onSurface) + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + } + ) +} + +@Composable +private fun EmptyState() { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(32.dp), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(32.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Icon( Icons.Default.Search, contentDescription = null, - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) Text( - text = "Search or filter photos", + "Advanced Search", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) Text( - text = "Try searching or tapping quick filters", + "Add people and tags to build your search", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -450,67 +545,33 @@ private fun EmptySearchState() { } @Composable -private fun NoResultsState(query: String, hasFilters: Boolean) { +private fun NoResultsState() { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(32.dp), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(32.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( Icons.Default.SearchOff, contentDescription = null, - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) Text( - text = "No results found", + "No photos found", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) - if (query.isNotBlank()) { - Text( - text = "No matches for \"$query\"", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (hasFilters) { - Text( - text = "Try removing some filters", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + "Try different filters", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } -} - -/** - * Get emoji for tag type - */ -private fun getTagEmoji(tagValue: String): String { - return when (tagValue) { - "night" -> "🌙" - "morning" -> "🌅" - "afternoon" -> "☀️" - "evening" -> "🌇" - "indoor" -> "🏠" - "outdoor" -> "🌲" - "group_photo" -> "👥" - "selfie" -> "🤳" - "couple" -> "💑" - "family" -> "👨‍👩‍👧" - "friend" -> "🤝" - "birthday" -> "🎂" - "high_res" -> "⭐" - "low_res" -> "📦" - "landscape" -> "🖼️" - "portrait" -> "📱" - "square" -> "⬜" - else -> "🏷️" - } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt index 4f57145..4910497 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt @@ -2,13 +2,13 @@ package com.placeholder.sherpai2.ui.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao +import com.placeholder.sherpai2.data.local.dao.PersonDao import com.placeholder.sherpai2.data.local.dao.TagDao import com.placeholder.sherpai2.data.local.entity.ImageEntity import com.placeholder.sherpai2.data.local.entity.PersonEntity import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity -import com.placeholder.sherpai2.data.local.entity.TagEntity import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository -import com.placeholder.sherpai2.domain.repository.ImageRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -16,219 +16,240 @@ import java.util.Calendar import javax.inject.Inject /** - * SearchViewModel - COMPLETE REDESIGN + * OPTIMIZED SearchViewModel with Boolean Logic * - * Features: - * - Near-match search ("low" → "low_res", "gro" → "group_photo") - * - Date range filtering - * - Quick tag filters - * - Clean person-only display - * - Simple/Verbose toggle + * PERFORMANCE: NO N+1 QUERIES! + * ✅ ImageAggregateDao loads tags via @Relation (1 query for 100 images!) + * ✅ Person cache for O(1) faceModelId lookups + * ✅ All filtering in memory (FAST) */ @HiltViewModel class SearchViewModel @Inject constructor( - private val imageRepository: ImageRepository, + private val imageAggregateDao: ImageAggregateDao, private val faceRecognitionRepository: FaceRecognitionRepository, + private val personDao: PersonDao, private val tagDao: TagDao ) : ViewModel() { - // Search query with near-match support private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() - // Active tag filters (quick chips) - private val _activeTagFilters = MutableStateFlow>(emptySet()) - val activeTagFilters: StateFlow> = _activeTagFilters.asStateFlow() + private val _includedPeople = MutableStateFlow>(emptySet()) + val includedPeople: StateFlow> = _includedPeople.asStateFlow() + + private val _excludedPeople = MutableStateFlow>(emptySet()) + val excludedPeople: StateFlow> = _excludedPeople.asStateFlow() + + private val _includedTags = MutableStateFlow>(emptySet()) + val includedTags: StateFlow> = _includedTags.asStateFlow() + + private val _excludedTags = MutableStateFlow>(emptySet()) + val excludedTags: StateFlow> = _excludedTags.asStateFlow() - // Date range filter private val _dateRange = MutableStateFlow(DateRange.ALL_TIME) val dateRange: StateFlow = _dateRange.asStateFlow() - // Display mode (simple = names only, verbose = icons + percentages) - private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE) - val displayMode: StateFlow = _displayMode.asStateFlow() + private val _availablePeople = MutableStateFlow>(emptyList()) + val availablePeople: StateFlow> = _availablePeople.asStateFlow() - // Available system tags for quick filters - private val _systemTags = MutableStateFlow>(emptyList()) - val systemTags: StateFlow> = _systemTags.asStateFlow() + private val _availableTags = MutableStateFlow>(emptyList()) + val availableTags: StateFlow> = _availableTags.asStateFlow() + + private val personCache = mutableMapOf() 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> { return combine( _searchQuery, - _activeTagFilters, + _includedPeople, + _excludedPeople, + _includedTags, + _excludedTags, _dateRange - ) { query, tagFilters, dateRange -> - Triple(query, tagFilters, dateRange) - }.flatMapLatest { (query, tagFilters, dateRange) -> - - channelFlow { - // Get matching tags FIRST (suspend call) - val matchingTags = if (query.isNotBlank()) { - findMatchingTags(query) - } else { - emptyList() - } - - // Get base images - val imagesFlow = when { - matchingTags.isNotEmpty() -> { - // Search by all matching tags - combine(matchingTags.map { tag -> - imageRepository.findImagesByTag(tag.value) - }) { results -> - results.flatMap { it }.distinctBy { it.image.imageId } + ) { values: Array<*> -> + SearchCriteria( + query = values[0] as String, + includedPeople = values[1] as Set, + excludedPeople = values[2] as Set, + includedTags = values[3] as Set, + excludedTags = values[4] as Set, + dateRange = values[5] as DateRange + ) + }.flatMapLatest { criteria -> + imageAggregateDao.observeAllImagesWithEverything() + .map { imagesList -> + imagesList.mapNotNull { imageWithEverything -> + if (!isInDateRange(imageWithEverything.image.capturedAt, criteria.dateRange)) { + return@mapNotNull null } - } - tagFilters.isNotEmpty() -> { - // Filter by active tags - combine(tagFilters.map { tagValue -> - imageRepository.findImagesByTag(tagValue) - }) { results -> - results.flatMap { it }.distinctBy { it.image.imageId } - } - } - else -> imageRepository.getAllImages() - } - // Apply date filtering and add face data - imagesFlow.collect { imagesList -> - val filtered = imagesList - .filter { imageWithEverything -> - isInDateRange(imageWithEverything.image.capturedAt, dateRange) - } - .map { imageWithEverything -> - // Get face tags with person info - val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons( - imageWithEverything.image.imageId - ) + val personIds = imageWithEverything.faceTags + .mapNotNull { faceTag -> personCache[faceTag.faceModelId] } + .toSet() + + val imageTags = imageWithEverything.tags + .map { it.value } + .toSet() + + val passesFilter = applyBooleanLogic( + personIds = personIds, + imageTags = imageTags, + criteria = criteria + ) + + if (passesFilter) { + val persons = personIds.mapNotNull { personId -> + _availablePeople.value.find { it.id == personId } + } ImageWithFaceTags( image = imageWithEverything.image, - faceTags = tagsWithPersons.map { it.first }, - persons = tagsWithPersons.map { it.second } + faceTags = imageWithEverything.faceTags, + persons = persons ) + } else { + null } - .sortedByDescending { it.image.capturedAt } - - send(filtered) + }.sortedByDescending { it.image.capturedAt } } - } } } - /** - * Near-match search: "low" matches "low_res", "gro" matches "group_photo" - */ - private suspend fun findMatchingTags(query: String): List { - val normalizedQuery = query.trim().lowercase() + private fun applyBooleanLogic( + personIds: Set, + imageTags: Set, + criteria: SearchCriteria + ): Boolean { + val hasAllIncludedPeople = if (criteria.includedPeople.isNotEmpty()) { + criteria.includedPeople.all { it in personIds } + } else true - // Get all system tags - val allTags = tagDao.getByType("SYSTEM") + val hasNoExcludedPeople = if (criteria.excludedPeople.isNotEmpty()) { + criteria.excludedPeople.none { it in personIds } + } else true - // Find tags that contain the query or match it closely - return allTags.filter { tag -> - val tagValue = tag.value.lowercase() + val hasAllIncludedTags = if (criteria.includedTags.isNotEmpty()) { + criteria.includedTags.all { it in imageTags } + } else true - // Exact match - tagValue == normalizedQuery || - // Contains match - tagValue.contains(normalizedQuery) || - // Starts with match - tagValue.startsWith(normalizedQuery) || - // Fuzzy match (remove underscores and compare) - tagValue.replace("_", "").contains(normalizedQuery.replace("_", "")) - }.sortedBy { tag -> - // Sort by relevance: exact > starts with > contains - when { - tag.value.lowercase() == normalizedQuery -> 0 - tag.value.lowercase().startsWith(normalizedQuery) -> 1 - else -> 2 - } - } + val hasNoExcludedTags = if (criteria.excludedTags.isNotEmpty()) { + criteria.excludedTags.none { it in imageTags } + } else true + + val matchesTextSearch = if (criteria.query.isNotBlank()) { + val normalizedQuery = criteria.query.trim().lowercase() + imageTags.any { tag -> tag.lowercase().contains(normalizedQuery) } + } else true + + return hasAllIncludedPeople && hasNoExcludedPeople && + hasAllIncludedTags && hasNoExcludedTags && + matchesTextSearch } - /** - * Load available system tags for quick filters - */ - private fun loadSystemTags() { + private fun loadAvailableFilters() { viewModelScope.launch { - val tags = tagDao.getByType("SYSTEM") + val people = personDao.getAllPersons() + _availablePeople.value = people.sortedBy { it.name } - // Get usage counts for all tags + val tags = tagDao.getByType("SYSTEM") val tagsWithUsage = tags.map { tag -> tag to tagDao.getTagUsageCount(tag.tagId) } - - // Sort by most commonly used - val sortedTags = tagsWithUsage + _availableTags.value = tagsWithUsage .sortedByDescending { (_, usageCount) -> usageCount } - .take(12) // Show top 12 most used tags - .map { (tag, _) -> tag } - - _systemTags.value = sortedTags + .take(30) + .map { (tag, _) -> tag.value } } } - /** - * Update search query - */ + fun includePerson(personId: String) { + _includedPeople.value = _includedPeople.value + personId + _excludedPeople.value = _excludedPeople.value - personId + } + + fun excludePerson(personId: String) { + _excludedPeople.value = _excludedPeople.value + personId + _includedPeople.value = _includedPeople.value - personId + } + + fun removePersonFilter(personId: String) { + _includedPeople.value = _includedPeople.value - personId + _excludedPeople.value = _excludedPeople.value - personId + } + + fun includeTag(tagValue: String) { + _includedTags.value = _includedTags.value + tagValue + _excludedTags.value = _excludedTags.value - tagValue + } + + fun excludeTag(tagValue: String) { + _excludedTags.value = _excludedTags.value + tagValue + _includedTags.value = _includedTags.value - tagValue + } + + fun removeTagFilter(tagValue: String) { + _includedTags.value = _includedTags.value - tagValue + _excludedTags.value = _excludedTags.value - tagValue + } + fun setSearchQuery(query: String) { _searchQuery.value = query } - /** - * Toggle a tag filter - */ - fun toggleTagFilter(tagValue: String) { - _activeTagFilters.value = if (tagValue in _activeTagFilters.value) { - _activeTagFilters.value - tagValue - } else { - _activeTagFilters.value + tagValue - } - } - - /** - * Clear all tag filters - */ - fun clearTagFilters() { - _activeTagFilters.value = emptySet() - } - - /** - * Set date range filter - */ fun setDateRange(range: DateRange) { _dateRange.value = range } - /** - * Toggle display mode (simple/verbose) - */ - fun toggleDisplayMode() { - _displayMode.value = when (_displayMode.value) { - DisplayMode.SIMPLE -> DisplayMode.VERBOSE - DisplayMode.VERBOSE -> DisplayMode.SIMPLE - } + fun clearAllFilters() { + _searchQuery.value = "" + _includedPeople.value = emptySet() + _excludedPeople.value = emptySet() + _includedTags.value = emptySet() + _excludedTags.value = emptySet() + _dateRange.value = DateRange.ALL_TIME } - /** - * Check if timestamp is in date range - */ - private fun isInDateRange(timestamp: Long, range: DateRange): Boolean { - return when (range) { - DateRange.ALL_TIME -> true - DateRange.TODAY -> isToday(timestamp) - DateRange.THIS_WEEK -> isThisWeek(timestamp) - DateRange.THIS_MONTH -> isThisMonth(timestamp) - DateRange.THIS_YEAR -> isThisYear(timestamp) - } + fun hasActiveFilters(): Boolean { + return _searchQuery.value.isNotBlank() || + _includedPeople.value.isNotEmpty() || + _excludedPeople.value.isNotEmpty() || + _includedTags.value.isNotEmpty() || + _excludedTags.value.isNotEmpty() || + _dateRange.value != DateRange.ALL_TIME + } + + fun getSearchSummary(): String { + val parts = mutableListOf() + if (_includedPeople.value.isNotEmpty()) parts.add("WITH: ${_includedPeople.value.size} people") + if (_excludedPeople.value.isNotEmpty()) parts.add("WITHOUT: ${_excludedPeople.value.size} people") + if (_includedTags.value.isNotEmpty()) parts.add("HAS: ${_includedTags.value.size} tags") + if (_excludedTags.value.isNotEmpty()) parts.add("NOT: ${_excludedTags.value.size} tags") + if (_dateRange.value != DateRange.ALL_TIME) parts.add(_dateRange.value.displayName) + return parts.joinToString(" • ") + } + + private fun isInDateRange(timestamp: Long, range: DateRange): Boolean = when (range) { + DateRange.ALL_TIME -> true + DateRange.TODAY -> isToday(timestamp) + DateRange.THIS_WEEK -> isThisWeek(timestamp) + DateRange.THIS_MONTH -> isThisMonth(timestamp) + DateRange.THIS_YEAR -> isThisYear(timestamp) } private fun isToday(timestamp: Long): Boolean { @@ -259,18 +280,21 @@ class SearchViewModel @Inject constructor( } } -/** - * Data class containing image with face recognition data - */ +private data class SearchCriteria( + val query: String, + val includedPeople: Set, + val excludedPeople: Set, + val includedTags: Set, + val excludedTags: Set, + val dateRange: DateRange +) + data class ImageWithFaceTags( val image: ImageEntity, val faceTags: List, val persons: List ) -/** - * Date range filters - */ enum class DateRange(val displayName: String) { ALL_TIME("All Time"), TODAY("Today"), @@ -279,10 +303,5 @@ enum class DateRange(val displayName: String) { THIS_YEAR("This Year") } -/** - * Display modes for photo tags - */ -enum class DisplayMode { - SIMPLE, // Just person names - VERBOSE // Names + icons + confidence percentages -} \ No newline at end of file +@Deprecated("No longer used") +enum class DisplayMode { SIMPLE, VERBOSE } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt index 28195f9..7a6f7c4 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt @@ -24,9 +24,24 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.placeholder.sherpai2.data.local.entity.TagWithUsage +/** + * CLEANED TagManagementScreen - No Scaffold wrapper + * + * Removed: + * - Scaffold wrapper (line 38) + * - Moved FAB inline as part of content + * + * Features: + * - Tag list with usage counts + * - Search functionality + * - Scanning progress + * - Delete tags + * - System/User tag distinction + */ @Composable fun TagManagementScreen( - viewModel: TagManagementViewModel = hiltViewModel() + viewModel: TagManagementViewModel = hiltViewModel(), + modifier: Modifier = Modifier ) { val uiState by viewModel.uiState.collectAsState() val scanningState by viewModel.scanningState.collectAsState() @@ -35,105 +50,8 @@ fun TagManagementScreen( var showScanMenu by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } - Scaffold( - floatingActionButton = { - // Single extended FAB with dropdown menu - var showMenu by remember { mutableStateOf(false) } - - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Dropdown menu for scan options - if (showMenu) { - Card( - modifier = Modifier.width(180.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - Column { - ListItem( - headlineContent = { Text("Scan All", style = MaterialTheme.typography.bodyMedium) }, - leadingContent = { - Icon( - Icons.Default.AutoFixHigh, - null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - }, - modifier = Modifier.clickable { - viewModel.scanForAllTags() - showMenu = false - } - ) - ListItem( - headlineContent = { Text("Base Tags", style = MaterialTheme.typography.bodyMedium) }, - leadingContent = { - Icon( - Icons.Default.PhotoCamera, - null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - }, - modifier = Modifier.clickable { - viewModel.scanForBaseTags() - showMenu = false - } - ) - ListItem( - headlineContent = { Text("Relationships", style = MaterialTheme.typography.bodyMedium) }, - leadingContent = { - Icon( - Icons.Default.People, - null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - }, - modifier = Modifier.clickable { - viewModel.scanForRelationshipTags() - showMenu = false - } - ) - ListItem( - headlineContent = { Text("Birthdays", style = MaterialTheme.typography.bodyMedium) }, - leadingContent = { - Icon( - Icons.Default.Cake, - null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - }, - modifier = Modifier.clickable { - viewModel.scanForBirthdayTags() - showMenu = false - } - ) - } - } - } - - // Main FAB - ExtendedFloatingActionButton( - onClick = { showMenu = !showMenu }, - icon = { - Icon( - if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh, - "Scan" - ) - }, - text = { Text(if (showMenu) "Close" else "Scan Tags") } - ) - } - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { // Stats Bar StatsBar(uiState) @@ -166,24 +84,60 @@ fun TagManagementScreen( } } is TagManagementViewModel.TagUiState.Success -> { - TagList( - tags = state.tags, - onDeleteTag = { viewModel.deleteTag(it) } - ) + if (state.tags.isEmpty()) { + EmptyTagsView() + } else { + TagList( + tags = state.tags, + onDeleteTag = { viewModel.deleteTag(it) } + ) + } } is TagManagementViewModel.TagUiState.Error -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text( - text = state.message, - color = MaterialTheme.colorScheme.error - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = state.message, + color = MaterialTheme.colorScheme.error + ) + } } } } } + + // FAB (inline, positioned over content) + ScanFAB( + showMenu = showScanMenu, + onToggleMenu = { showScanMenu = !showScanMenu }, + onScanAll = { + viewModel.scanForAllTags() + showScanMenu = false + }, + onScanBase = { + viewModel.scanForBaseTags() + showScanMenu = false + }, + onScanRelationships = { + viewModel.scanForRelationshipTags() + showScanMenu = false + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) } // Add Tag Dialog @@ -196,73 +150,77 @@ fun TagManagementScreen( } ) } - - // Scan Menu - if (showScanMenu) { - ScanMenuDialog( - onDismiss = { showScanMenu = false }, - onScanSelected = { scanType -> - when (scanType) { - TagManagementViewModel.ScanType.BASE_TAGS -> viewModel.scanForBaseTags() - TagManagementViewModel.ScanType.RELATIONSHIP_TAGS -> viewModel.scanForRelationshipTags() - TagManagementViewModel.ScanType.BIRTHDAY_TAGS -> viewModel.scanForBirthdayTags() - TagManagementViewModel.ScanType.SCENE_TAGS -> viewModel.scanForSceneTags() - TagManagementViewModel.ScanType.ALL -> viewModel.scanForAllTags() - } - showScanMenu = false - } - ) - } } +/** + * Stats bar at top + */ @Composable private fun StatsBar(uiState: TagManagementViewModel.TagUiState) { - if (uiState is TagManagementViewModel.TagUiState.Success) { - Card( + val (totalTags, totalPhotos) = when (uiState) { + is TagManagementViewModel.TagUiState.Success -> { + val photoCount: Int = uiState.tags.sumOf { it.usageCount } + uiState.tags.size to photoCount + } + else -> 0 to 0 + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) { + Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + horizontalArrangement = Arrangement.SpaceEvenly ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceAround - ) { - StatItem("Total", uiState.totalTags.toString(), Icons.Default.Label) - StatItem("System", uiState.systemTags.toString(), Icons.Default.AutoAwesome) - StatItem("User", uiState.userTags.toString(), Icons.Default.PersonOutline) - } + StatItem( + icon = Icons.Default.Label, + value = totalTags.toString(), + label = "Tags" + ) + VerticalDivider( + modifier = Modifier.height(48.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + StatItem( + icon = Icons.Default.Photo, + value = totalPhotos.toString(), + label = "Tagged Photos" + ) } } } @Composable -private fun StatItem(label: String, value: String, icon: ImageVector) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { +private fun StatItem(icon: ImageVector, value: String, label: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { Icon( - imageVector = icon, + icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(4.dp)) Text( - text = value, + value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) Text( - text = label, - style = MaterialTheme.typography.bodySmall, + label, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } +/** + * Search bar + */ @Composable private fun SearchBar( searchQuery: String, @@ -273,9 +231,9 @@ private fun SearchBar( onValueChange = onSearchChange, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(16.dp), placeholder = { Text("Search tags...") }, - leadingIcon = { Icon(Icons.Default.Search, "Search") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, trailingIcon = { if (searchQuery.isNotEmpty()) { IconButton(onClick = { onSearchChange("") }) { @@ -283,96 +241,124 @@ private fun SearchBar( } } }, - singleLine = true + singleLine = true, + shape = RoundedCornerShape(16.dp) ) } +/** + * Scanning progress indicator + */ @Composable private fun ScanningProgress( scanningState: TagManagementViewModel.TagScanningState, viewModel: TagManagementViewModel ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - when (scanningState) { - is TagManagementViewModel.TagScanningState.Scanning -> { - Text( - text = "Scanning: ${scanningState.scanType.name.replace("_", " ")}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(8.dp)) - LinearProgressIndicator( - progress = { scanningState.progress.toFloat() / scanningState.total.toFloat() }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${scanningState.progress} / ${scanningState.total} images", - style = MaterialTheme.typography.bodySmall - ) - Text( - text = "Tags applied: ${scanningState.tagsApplied}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - is TagManagementViewModel.TagScanningState.Complete -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = "✓ Scan Complete", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "${scanningState.tagsApplied} tags applied to ${scanningState.imagesProcessed} images", - style = MaterialTheme.typography.bodySmall - ) - } - IconButton(onClick = { viewModel.resetScanningState() }) { - Icon(Icons.Default.Close, "Close") - } - } - } - is TagManagementViewModel.TagScanningState.Error -> { + when (scanningState) { + is TagManagementViewModel.TagScanningState.Scanning -> { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Error: ${scanningState.message}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error + "Scanning: ${scanningState.scanType}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold ) - IconButton(onClick = { viewModel.resetScanningState() }) { - Icon(Icons.Default.Close, "Close") + Text( + "${scanningState.progress}/${scanningState.total}", + style = MaterialTheme.typography.bodySmall + ) + } + LinearProgressIndicator( + progress = { + if (scanningState.total > 0) { + scanningState.progress.toFloat() / scanningState.total.toFloat() + } else { + 0f + } + }, + modifier = Modifier.fillMaxWidth() + ) + Text( + "Tags applied: ${scanningState.tagsApplied}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + if (scanningState.currentImage.isNotEmpty()) { + Text( + "Current: ${scanningState.currentImage}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + is TagManagementViewModel.TagScanningState.Complete -> { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + "Scan Complete!", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + "Processed: ${scanningState.imagesProcessed} images", + style = MaterialTheme.typography.bodySmall + ) + Text( + "Applied: ${scanningState.tagsApplied} tags", + style = MaterialTheme.typography.bodySmall + ) + if (scanningState.newTagsCreated > 0) { + Text( + "Created: ${scanningState.newTagsCreated} new tags", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) } } } - else -> { /* Idle - don't show */ } } } + else -> {} } } +/** + * Tag list + */ @Composable private fun TagList( tags: List, @@ -383,114 +369,238 @@ private fun TagList( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(tags, key = { it.tagId }) { tag -> - TagListItem(tag, onDeleteTag) + items(tags) { tagWithUsage -> + TagCard( + tagWithUsage = tagWithUsage, + onDelete = { onDeleteTag(tagWithUsage.tagId) } + ) } } } +/** + * Individual tag card + */ @Composable -private fun TagListItem( - tag: TagWithUsage, - onDeleteTag: (String) -> Unit +private fun TagCard( + tagWithUsage: TagWithUsage, + onDelete: () -> Unit ) { - var showDeleteConfirm by remember { mutableStateOf(false) } + val isSystemTag = tagWithUsage.type == "SYSTEM" Card( modifier = Modifier.fillMaxWidth(), - onClick = { /* TODO: Navigate to images with this tag */ } + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row( + modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Tag type icon - Icon( - imageVector = if (tag.type == "SYSTEM") Icons.Default.AutoAwesome else Icons.Default.Label, - contentDescription = null, - tint = if (tag.type == "SYSTEM") - MaterialTheme.colorScheme.secondary + // Tag icon + Surface( + modifier = Modifier.size(40.dp), + shape = RoundedCornerShape(8.dp), + color = if (isSystemTag) + MaterialTheme.colorScheme.primaryContainer else - MaterialTheme.colorScheme.primary - ) + MaterialTheme.colorScheme.secondaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + if (isSystemTag) Icons.Default.AutoAwesome else Icons.Default.Label, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isSystemTag) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + // Tag info Column { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = tagWithUsage.value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + if (isSystemTag) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) { + Text( + "SYSTEM", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } Text( - text = tag.value, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = if (tag.type == "SYSTEM") "System tag" else "User tag", + text = "${tagWithUsage.usageCount} ${if (tagWithUsage.usageCount == 1) "photo" else "photos"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Usage count badge - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primaryContainer - ) { - Text( - text = tag.usageCount.toString(), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer + // Delete button (only for user tags) + if (!isSystemTag) { + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error ) } - - // Delete button (only for user tags) - if (tag.type == "GENERIC") { - IconButton(onClick = { showDeleteConfirm = true }) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete tag", - tint = MaterialTheme.colorScheme.error - ) - } - } } } } +} - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - title = { Text("Delete Tag?") }, - text = { Text("Are you sure you want to delete '${tag.value}'? This will remove it from ${tag.usageCount} images.") }, - confirmButton = { - TextButton( - onClick = { - onDeleteTag(tag.tagId) - showDeleteConfirm = false - } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { - Text("Cancel") - } +/** + * Empty state + */ +@Composable +private fun EmptyTagsView() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.LabelOff, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + Text( + "No Tags Yet", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + "Scan your photos to generate tags automatically", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } +} + +/** + * Floating Action Button with scan menu + */ +@Composable +private fun ScanFAB( + showMenu: Boolean, + onToggleMenu: () -> Unit, + onScanAll: () -> Unit, + onScanBase: () -> Unit, + onScanRelationships: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Menu options + AnimatedVisibility(visible = showMenu) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SmallFAB( + icon = Icons.Default.AutoFixHigh, + text = "Scan All", + onClick = onScanAll + ) + SmallFAB( + icon = Icons.Default.PhotoCamera, + text = "Base Tags", + onClick = onScanBase + ) + SmallFAB( + icon = Icons.Default.People, + text = "Relationships", + onClick = onScanRelationships + ) } + } + + // Main FAB + ExtendedFloatingActionButton( + onClick = onToggleMenu, + icon = { + Icon( + if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh, + "Scan" + ) + }, + text = { Text(if (showMenu) "Close" else "Scan Tags") }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) } } +@Composable +private fun SmallFAB( + icon: ImageVector, + text: String, + onClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp + ) { + Text( + text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } + + FloatingActionButton( + onClick = onClick, + modifier = Modifier.size(48.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Icon(icon, contentDescription = text, modifier = Modifier.size(20.dp)) + } + } +} + +/** + * Add tag dialog + */ @Composable private fun AddTagDialog( onDismiss: () -> Unit, @@ -500,18 +610,19 @@ private fun AddTagDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Add New Tag") }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + title = { Text("Add Custom Tag") }, text = { OutlinedTextField( value = tagName, onValueChange = { tagName = it }, - label = { Text("Tag name") }, + label = { Text("Tag Name") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) }, confirmButton = { - TextButton( + Button( onClick = { onConfirm(tagName) }, enabled = tagName.isNotBlank() ) { @@ -524,101 +635,4 @@ private fun AddTagDialog( } } ) -} - -@Composable -private fun ScanMenuDialog( - onDismiss: () -> Unit, - onScanSelected: (TagManagementViewModel.ScanType) -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Scan for Tags") }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - ScanOption( - title = "Base Tags", - description = "Face count, orientation, time, quality", - icon = Icons.Default.PhotoCamera, - onClick = { onScanSelected(TagManagementViewModel.ScanType.BASE_TAGS) } - ) - ScanOption( - title = "Relationship Tags", - description = "Family, friends, colleagues", - icon = Icons.Default.People, - onClick = { onScanSelected(TagManagementViewModel.ScanType.RELATIONSHIP_TAGS) } - ) - ScanOption( - title = "Birthday Tags", - description = "Photos near birthdays", - icon = Icons.Default.Cake, - onClick = { onScanSelected(TagManagementViewModel.ScanType.BIRTHDAY_TAGS) } - ) - ScanOption( - title = "Scene Tags", - description = "Indoor/outdoor detection", - icon = Icons.Default.Landscape, - onClick = { onScanSelected(TagManagementViewModel.ScanType.SCENE_TAGS) } - ) - Divider() - ScanOption( - title = "Scan All", - description = "Run all scans", - icon = Icons.Default.AutoFixHigh, - onClick = { onScanSelected(TagManagementViewModel.ScanType.ALL) } - ) - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -@Composable -private fun ScanOption( - title: String, - description: String, - icon: ImageVector, - onClick: () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(32.dp) - ) - Column { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt index db10908..ae6a05b 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt @@ -20,25 +20,31 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.FaceDetection +import androidx.compose.ui.graphics.Color import com.google.mlkit.vision.face.FaceDetectorOptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext -import java.io.InputStream /** - * Dialog for selecting a face from multiple detected faces + * MINIMAL FacePickerDialog - Optimized for batch processing 30-50 photos * - * CRITICAL: Re-detects faces on full resolution bitmap to ensure accurate cropping. - * Face bounds from FaceDetectionHelper are from downsampled images and won't match - * the full resolution bitmap loaded here. + * REMOVED CLUTTER: + * - "Preview (tap to select)" header + * - "Face will be used for training" info box + * - "Face #" labels covering previews + * - Original image preview + * + * IMPROVED: + * - Larger face previews (1:1 aspect ratio) + * - Clean checkmark overlay only + * - Minimal text + * - Fast workflow */ @Composable fun FacePickerDialog( @@ -107,9 +113,9 @@ fun FacePickerDialog( ) { Card( modifier = Modifier - .fillMaxWidth(0.94f) + .fillMaxWidth(0.92f) .wrapContentHeight(), - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) @@ -117,175 +123,70 @@ fun FacePickerDialog( Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Header + // Minimal header - just close button Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column { - Text( - text = "Pick a Face", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = "${result.faceCount} faces detected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = "${result.faceCount} faces", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) IconButton(onClick = onDismiss) { - Icon( - Icons.Default.Close, - contentDescription = "Close", - modifier = Modifier.size(24.dp) - ) + Icon(Icons.Default.Close, contentDescription = "Close") } } - // Instruction - Text( - text = "Tap a face below to select it for training:", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (isLoading) { - // Loading state + // Loading state - minimal Box( modifier = Modifier .fillMaxWidth() - .height(200.dp), + .height(180.dp), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text( - "Processing faces...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + CircularProgressIndicator() } } else if (errorMessage != null) { - // Error state - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - shape = RoundedCornerShape(12.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Error, - contentDescription = null, - tint = MaterialTheme.colorScheme.error - ) - Text( - text = errorMessage ?: "Unknown error", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - } else { - // Original image preview - Card( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(16.dp) - ) { - AsyncImage( - model = result.uri, - contentDescription = "Original image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) - } - - // Face previews section + // Error state - minimal Text( - text = "Preview (tap to select):", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium ) - - // Face preview cards + } else { + // CLEAN face grid - NO labels, NO text Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { croppedFaces.forEachIndexed { index, faceBitmap -> - FacePreviewCard( + CleanFaceCard( faceBitmap = faceBitmap, - index = index + 1, isSelected = selectedFaceIndex == index, onClick = { selectedFaceIndex = index }, modifier = Modifier.weight(1f) ) } } - - // Helper text - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Info, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - "The selected face will be used for training", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } } - // Action buttons + // Action buttons - minimal Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - OutlinedButton( + TextButton( onClick = onDismiss, - modifier = Modifier - .weight(1f) - .height(52.dp), - shape = RoundedCornerShape(14.dp) + modifier = Modifier.weight(1f) ) { - Text("Cancel", style = MaterialTheme.typography.titleMedium) + Text("Skip") } Button( @@ -294,19 +195,16 @@ fun FacePickerDialog( onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex]) } }, - modifier = Modifier - .weight(1f) - .height(52.dp), - enabled = !isLoading && croppedFaces.isNotEmpty() && errorMessage == null, - shape = RoundedCornerShape(14.dp) + enabled = !isLoading && croppedFaces.isNotEmpty(), + modifier = Modifier.weight(1f) ) { Icon( - Icons.Default.CheckCircle, + Icons.Default.Check, contentDescription = null, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(18.dp) ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Use This Face", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.width(6.dp)) + Text("Use") } } } @@ -315,102 +213,67 @@ fun FacePickerDialog( } /** - * Individual face preview card + * ULTRA-CLEAN face card - NO TEXT, just image + checkmark + * + * CHANGES: + * - 1:1 aspect ratio (bigger!) + * - NO "Face #" label + * - Checkmark in corner only + * - Minimal border */ @Composable -private fun FacePreviewCard( +private fun CleanFaceCard( faceBitmap: Bitmap, - index: Int, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { Card( modifier = modifier - .aspectRatio(0.75f) + .aspectRatio(1f) // SQUARE = bigger previews! .clickable(onClick = onClick), colors = CardDefaults.cardColors( - containerColor = if (isSelected) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant + containerColor = MaterialTheme.colorScheme.surfaceVariant ), border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else - BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), - shape = RoundedCornerShape(16.dp), + BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation( - defaultElevation = if (isSelected) 8.dp else 2.dp + defaultElevation = if (isSelected) 4.dp else 1.dp ) ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Face image - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - Image( - bitmap = faceBitmap.asImageBitmap(), - contentDescription = "Face $index", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) + Box(modifier = Modifier.fillMaxSize()) { + // Face image - FULL SIZE + Image( + bitmap = faceBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) - // Selected overlay with checkmark - if (isSelected) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary, - shadowElevation = 4.dp - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Selected", - modifier = Modifier - .padding(12.dp) - .size(40.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } + // Checkmark in corner - ONLY if selected + if (isSelected) { + Surface( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(32.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + shadowElevation = 4.dp + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + modifier = Modifier + .padding(6.dp) + .size(20.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) } } - - // Face number label - Surface( - modifier = Modifier.fillMaxWidth(), - color = if (isSelected) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) - ) { - Text( - text = "Face $index", - modifier = Modifier.padding(vertical = 12.dp), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - color = if (isSelected) - MaterialTheme.colorScheme.onPrimary - else - MaterialTheme.colorScheme.onSurface - ) - } } } } @@ -423,7 +286,7 @@ private suspend fun loadFullResolutionBitmap( uri: Uri ): Bitmap? = withContext(Dispatchers.IO) { try { - val inputStream: InputStream? = context.contentResolver.openInputStream(uri) + val inputStream = context.contentResolver.openInputStream(uri) BitmapFactory.decodeStream(inputStream)?.also { inputStream?.close() } @@ -437,19 +300,18 @@ private suspend fun loadFullResolutionBitmap( */ private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List = withContext(Dispatchers.Default) { try { - val faceDetectorOptions = FaceDetectorOptions.Builder() + val options = FaceDetectorOptions.Builder() .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) - .setMinFaceSize(0.15f) + .setMinFaceSize(0.10f) .build() - val detector = FaceDetection.getClient(faceDetectorOptions) - val inputImage = InputImage.fromBitmap(bitmap, 0) + val detector = FaceDetection.getClient(options) + val image = InputImage.fromBitmap(bitmap, 0) + val faces = detector.process(image).await() - val faces = detector.process(inputImage).await() - - // Sort by face size (largest first) + // Sort by size (largest first) faces.sortedByDescending { face -> face.boundingBox.width() * face.boundingBox.height() }.map { it.boundingBox } @@ -460,7 +322,7 @@ private suspend fun detectFacesOnBitmap(bitmap: Bitmap): List = withContex } /** - * Helper function to crop face from bitmap with padding + * Crop face from bitmap with padding */ private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap { // Add 20% padding around the face diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvedfacepickerdialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvedfacepickerdialog.kt deleted file mode 100644 index ee5d163..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvedfacepickerdialog.kt +++ /dev/null @@ -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>(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) -} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt index cd873d4..e16a91d 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt @@ -128,7 +128,7 @@ fun ScanResultsScreen( // Face Picker Dialog showFacePickerDialog?.let { result -> - ImprovedFacePickerDialog( // CHANGED + FacePickerDialog ( // CHANGED result = result, onDismiss = { showFacePickerDialog = null }, onFaceSelected = { faceIndex, croppedFaceBitmap -> diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt index 37de939..ffce381 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt @@ -13,97 +13,84 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import java.text.SimpleDateFormat -import java.util.* -import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog - /** - * Beautiful TrainingScreen with person info capture + * CLEANED TrainingScreen - No duplicate header + * + * Removed: + * - Scaffold wrapper (lines 46-55) + * - TopAppBar (was creating banner) + * - "Train New Person" title (MainScreen shows it) * * Features: - * - Name input - * - Date of birth picker - * - Relationship selector + * - Person info capture (name, DOB, relationship) * - Onboarding cards * - Beautiful gradient design * - Clear call to action + * - Scrollable on small screens */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrainingScreen( onSelectImages: () -> Unit, modifier: Modifier = Modifier, trainViewModel: TrainViewModel = hiltViewModel() - ) { var showInfoDialog by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Train New Person") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + + // Hero section with gradient + HeroCard() + + // How it works section + HowItWorksSection() + + // Requirements section + RequirementsCard() + + Spacer(Modifier.weight(1f)) + + // Main CTA button + Button( + onClick = { showInfoDialog = true }, + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape(16.dp) + ) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + "Start Training", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) } - ) { paddingValues -> - Column( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - // Hero section with gradient - HeroCard() - - // How it works section - HowItWorksSection() - - // Requirements section - RequirementsCard() - - Spacer(Modifier.weight(1f)) - - // Main CTA button - Button( - onClick = { showInfoDialog = true }, - modifier = Modifier - .fillMaxWidth() - .height(60.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - shape = RoundedCornerShape(16.dp) - ) { - Icon( - Icons.Default.PersonAdd, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Spacer(Modifier.width(12.dp)) - Text( - "Start Training", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - } - - Spacer(Modifier.height(8.dp)) - } + Spacer(Modifier.height(8.dp)) } // Person info dialog if (showInfoDialog) { - BeautifulPersonInfoDialog( // CHANGED + BeautifulPersonInfoDialog( onDismiss = { showInfoDialog = false }, onConfirm = { name, dob, relationship -> showInfoDialog = false @@ -200,16 +187,16 @@ private fun HowItWorksSection() { StepCard( number = 3, - icon = Icons.Default.ModelTraining, - title = "AI Learns Their Face", - description = "Takes ~30 seconds to train" + icon = Icons.Default.SmartToy, + title = "AI Training", + description = "We'll create a recognition model" ) StepCard( number = 4, - icon = Icons.Default.Search, - title = "Auto-Tag Your Library", - description = "Find them in all your photos" + icon = Icons.Default.AutoFixHigh, + title = "Auto-Tag Photos", + description = "Find this person across your library" ) } } @@ -217,31 +204,31 @@ private fun HowItWorksSection() { @Composable private fun StepCard( number: Int, - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, title: String, description: String ) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(16.dp) ) { Row( modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically ) { - // Number badge + // Number circle Surface( + modifier = Modifier.size(48.dp), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) + color = MaterialTheme.colorScheme.primary ) { Box(contentAlignment = Alignment.Center) { Text( - text = number.toString(), + "$number", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimary @@ -249,6 +236,7 @@ private fun StepCard( } } + // Content Column(modifier = Modifier.weight(1f)) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -266,7 +254,6 @@ private fun StepCard( fontWeight = FontWeight.SemiBold ) } - Spacer(Modifier.height(4.dp)) Text( description, style = MaterialTheme.typography.bodyMedium, @@ -282,7 +269,7 @@ private fun RequirementsCard() { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) ), shape = RoundedCornerShape(16.dp) ) { @@ -297,225 +284,59 @@ private fun RequirementsCard() { Icon( Icons.Default.CheckCircle, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) ) Text( - "What You'll Need", + "Best Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) } - RequirementItem("20-30 photos of the person", true) - RequirementItem("Different angles and lighting", true) - RequirementItem("Clear face visibility", true) - RequirementItem("Mix of expressions", true) - RequirementItem("2-3 minutes of your time", true) - } - } -} + RequirementItem( + icon = Icons.Default.PhotoCamera, + text = "20-30 photos minimum" + ) -@Composable -private fun RequirementItem(text: String, isMet: Boolean) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - if (isMet) Icons.Default.Check else Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (isMet) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - } - ) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium - ) - } -} + RequirementItem( + icon = Icons.Default.Face, + text = "Clear, well-lit face photos" + ) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PersonInfoDialog( - onDismiss: () -> Unit, - onConfirm: (name: String, dateOfBirth: Long?, relationship: String) -> Unit -) { - var name by remember { mutableStateOf("") } - var dateOfBirth by remember { mutableStateOf(null) } - var selectedRelationship by remember { mutableStateOf("Other") } - var showDatePicker by remember { mutableStateOf(false) } + RequirementItem( + icon = Icons.Default.Diversity1, + text = "Variety of angles & expressions" + ) - val relationships = listOf( - "Family" to "👨‍👩‍👧‍👦", - "Friend" to "🤝", - "Partner" to "❤️", - "Child" to "👶", - "Parent" to "👪", - "Sibling" to "👫", - "Colleague" to "💼", - "Other" to "👤" - ) - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Column { - Text("Person Details") - Text( - "Help us organize your photos", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Name field - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name *") }, - placeholder = { Text("e.g., John Doe") }, - leadingIcon = { - Icon(Icons.Default.Person, contentDescription = null) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - // Date of birth - OutlinedButton( - onClick = { showDatePicker = true }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Cake, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text( - if (dateOfBirth != null) { - "Birthday: ${formatDate(dateOfBirth!!)}" - } else { - "Add Birthday (Optional)" - } - ) - } - - // Relationship selector - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "Relationship", - style = MaterialTheme.typography.labelMedium - ) - - // Relationship chips - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - relationships.take(4).forEach { (rel, emoji) -> - FilterChip( - selected = selectedRelationship == rel, - onClick = { selectedRelationship = rel }, - label = { Text("$emoji $rel") } - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - relationships.drop(4).forEach { (rel, emoji) -> - FilterChip( - selected = selectedRelationship == rel, - onClick = { selectedRelationship = rel }, - label = { Text("$emoji $rel") } - ) - } - } - } - - // Privacy note - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - "All data stays on your device", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isNotBlank()) { - onConfirm(name, dateOfBirth, selectedRelationship) - } - }, - enabled = name.isNotBlank() - ) { - Text("Continue") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) - - // Date picker dialog - if (showDatePicker) { - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton( - onClick = { - // Get selected date from date picker - // For now, set to current date as placeholder - dateOfBirth = System.currentTimeMillis() - showDatePicker = false - } - ) { - Text("OK") - } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text("Cancel") - } - } - ) { - // Material3 DatePicker - DatePicker( - state = rememberDatePickerState(), - modifier = Modifier.padding(16.dp) + RequirementItem( + icon = Icons.Default.HighQuality, + text = "Good quality images" ) } } } -private fun formatDate(timestamp: Long): String { - val formatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - return formatter.format(Date(timestamp)) +@Composable +private fun RequirementItem( + icon: ImageVector, + text: String +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt index fe6f689..92a7631 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt @@ -17,77 +17,64 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.placeholder.sherpai2.ui.utilities.stats.StatsScreen /** - * PhotoUtilitiesScreen - UPDATED with Stats tab + * CLEANED PhotoUtilitiesScreen - No duplicate header + * + * Removed: + * - Scaffold wrapper (lines 36-74) + * - TopAppBar (was creating banner) + * - "Photo Utilities" title (MainScreen shows it) * * Features: * - Stats tab (photo statistics and analytics) * - Tools tab (scan, duplicates, bursts, quality) + * - Clean TabRow navigation */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PhotoUtilitiesScreen( - viewModel: PhotoUtilitiesViewModel = hiltViewModel() + viewModel: PhotoUtilitiesViewModel = hiltViewModel(), + modifier: Modifier = Modifier ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle() var selectedTab by remember { mutableStateOf(0) } - Scaffold( - topBar = { - Column { - TopAppBar( - title = { - Column { - Text( - "Photo Utilities", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - "Manage your photo collection", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - ) - ) - - TabRow(selectedTabIndex = selectedTab) { - Tab( - selected = selectedTab == 0, - onClick = { selectedTab = 0 }, - text = { Text("Stats") }, - icon = { Icon(Icons.Default.BarChart, "Statistics") } - ) - Tab( - selected = selectedTab == 1, - onClick = { selectedTab = 1 }, - text = { Text("Tools") }, - icon = { Icon(Icons.Default.Build, "Tools") } - ) - } - } + Column(modifier = modifier.fillMaxSize()) { + // TabRow for Stats/Tools + TabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Stats") }, + icon = { Icon(Icons.Default.BarChart, "Statistics") } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Tools") }, + icon = { Icon(Icons.Default.Build, "Tools") } + ) } - ) { paddingValues -> + + // Tab content when (selectedTab) { 0 -> { - // Stats tab - delegate to StatsScreen + // Stats tab StatsScreen() } 1 -> { - // Tools tab - existing utilities + // Tools tab ToolsTabContent( uiState = uiState, scanProgress = scanProgress, onScanPhotos = { viewModel.scanForPhotos() }, onDetectDuplicates = { viewModel.detectDuplicates() }, onDetectBursts = { viewModel.detectBursts() }, - onAnalyzeQuality = { viewModel.analyzeQuality() }, - modifier = Modifier.padding(paddingValues) + onAnalyzeQuality = { viewModel.analyzeQuality() } ) } } @@ -257,13 +244,13 @@ private fun SectionHeader( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 4.dp) ) { Icon( icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(20.dp) ) Text( text = title, @@ -285,52 +272,53 @@ private fun UtilityCard( ) { Card( modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Icon - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(56.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Box(contentAlignment = Alignment.Center) { - Icon( - icon, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - // Text - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Button Button( onClick = onClick, - enabled = enabled + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + shape = RoundedCornerShape(12.dp) ) { Text(buttonText) } @@ -343,43 +331,34 @@ private fun ProgressCard(progress: ScanProgress) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) ) ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( text = progress.message, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium ) - if (progress.total > 0) { - Text( - text = "${progress.current} / ${progress.total}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } + Text( + text = "${progress.current}/${progress.total}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - if (progress.total > 0) { - LinearProgressIndicator( - progress = { progress.current.toFloat() / progress.total.toFloat() }, - modifier = Modifier.fillMaxWidth() - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth() - ) - } + LinearProgressIndicator( + progress = { progress.current.toFloat() / progress.total.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -393,15 +372,11 @@ private fun ResultCard( ) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = iconTint.copy(alpha = 0.1f) - ) + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( @@ -410,9 +385,7 @@ private fun ResultCard( tint = iconTint, modifier = Modifier.size(32.dp) ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + Column { Text( text = title, style = MaterialTheme.typography.titleMedium, @@ -420,7 +393,8 @@ private fun ResultCard( ) Text( text = message, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -432,62 +406,25 @@ private fun InfoCard() { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) ) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = "How It Works", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - } - - InfoItem( - "Duplicates", - "Finds exact duplicates by comparing file content" + Icon( + Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(20.dp) ) - InfoItem( - "Bursts", - "Groups 3+ photos taken within 2 seconds. Tags one as 'representative' for albums" - ) - InfoItem( - "Quality", - "Detects screenshots by screen dimensions. Blurry detection coming soon" + Text( + text = "These tools help you organize and maintain your photo collection", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer ) } } -} - -@Composable -private fun InfoItem(title: String, description: String) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = "• $title", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 12.dp) - ) - } } \ No newline at end of file