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