Added onClick from Albumviewscreen.kt

This commit is contained in:
genki
2026-01-10 22:00:23 -05:00
parent 11a1a33764
commit 52c5643b5b
5 changed files with 356 additions and 108 deletions

View File

@@ -85,4 +85,7 @@ dependencies {
// Gson for storing FloatArrays in Room
implementation(libs.gson)
// Zoomable
implementation(libs.zoomable)
}

View File

@@ -1,6 +1,7 @@
package com.placeholder.sherpai2.ui.album
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.*
@@ -23,14 +24,12 @@ import com.placeholder.sherpai2.ui.search.DisplayMode
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
/**
* AlbumViewScreen - Beautiful album detail view
* AlbumViewScreen - UPDATED with clickable images
*
* Features:
* - Album stats
* - Search within album
* - Date filtering
* - Simple/Verbose toggle
* - Clean person display
* Changes:
* - PhotoCard now clickable
* - Passes onImageClick to ImageGridItem
* - Entire card surface clickable as backup
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -279,6 +278,9 @@ private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, labe
}
}
/**
* PhotoCard - UPDATED: Now fully clickable
*/
@Composable
private fun PhotoCard(
photo: AlbumPhoto,
@@ -286,15 +288,19 @@ private fun PhotoCard(
onImageClick: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable { onImageClick(photo.image.imageUri) }, // ✅ ENTIRE CARD CLICKABLE
shape = RoundedCornerShape(12.dp)
) {
Column {
// Image (also clickable via ImageGridItem)
ImageGridItem(
image = photo.image,
onClick = { onImageClick(photo.image.imageUri) }
onClick = { onImageClick(photo.image.imageUri) } // ✅ IMAGE CLICKABLE
)
// Person tags
if (photo.persons.isNotEmpty()) {
when (displayMode) {
DisplayMode.SIMPLE -> {

View File

@@ -1,86 +1,322 @@
package com.placeholder.sherpai2.ui.imagedetail
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.ui.imagedetail.viewmodel.ImageDetailViewModel
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.net.URLEncoder
/**
* ImageDetailScreen
* ImageDetailScreen - COMPLETE with navigation and tags
*
* Purpose:
* - Add tags
* - Remove tags
* - Validate write propagation
* Features:
* - Full-screen zoomable image
* - Previous/Next navigation buttons
* - Image counter (3/45)
* - Tags button (toggle show/hide)
* - Shows all tags on photo
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageDetailScreen(
modifier: Modifier = Modifier,
imageUri: String,
onBack: () -> Unit
onBack: () -> Unit,
navController: NavController? = null,
allImageUris: List<String> = emptyList(), // Pass from caller
viewModel: ImageDetailViewModel = hiltViewModel() // ✅ FIXED: Use hiltViewModel
) {
val viewModel: ImageDetailViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
LaunchedEffect(imageUri) {
viewModel.loadImage(imageUri)
}
val tags by viewModel.tags.collectAsStateWithLifecycle()
var showTags by remember { mutableStateOf(false) }
var newTag by remember { mutableStateOf("") }
// Navigation state
val currentIndex = if (allImageUris.isNotEmpty()) allImageUris.indexOf(imageUri) else -1
val canGoPrevious = currentIndex > 0
val canGoNext = currentIndex in 0 until allImageUris.size - 1
Column(
modifier = modifier
.fillMaxSize()
.padding(12.dp)
) {
Scaffold(
topBar = {
TopAppBar(
title = {
if (currentIndex >= 0) {
Text(
"${currentIndex + 1} / ${allImageUris.size}",
style = MaterialTheme.typography.titleMedium
)
} else {
Text("Photo")
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
// Tags toggle button
IconButton(onClick = { showTags = !showTags }) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (tags.isNotEmpty()) {
Badge(
containerColor = if (showTags)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
) {
Text(
tags.size.toString(),
color = if (showTags)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Icon(
if (showTags) Icons.Default.Label else Icons.Default.LocalOffer,
"Show Tags",
tint = if (showTags)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
AsyncImage(
model = imageUri,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
// Previous button
if (navController != null && allImageUris.isNotEmpty()) {
IconButton(
onClick = {
if (canGoPrevious) {
val prevUri = allImageUris[currentIndex - 1]
val encoded = URLEncoder.encode(prevUri, "UTF-8")
navController.navigate("image_detail/$encoded") {
popUpTo("image_detail/${URLEncoder.encode(imageUri, "UTF-8")}") {
inclusive = true
}
}
}
},
enabled = canGoPrevious
) {
Icon(Icons.Default.KeyboardArrowLeft, "Previous")
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = newTag,
onValueChange = { newTag = it },
label = { Text("Add tag") },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
viewModel.addTag(newTag)
newTag = ""
},
modifier = Modifier.padding(top = 8.dp)
) {
Text("Add Tag")
// Next button
IconButton(
onClick = {
if (canGoNext) {
val nextUri = allImageUris[currentIndex + 1]
val encoded = URLEncoder.encode(nextUri, "UTF-8")
navController.navigate("image_detail/$encoded") {
popUpTo("image_detail/${URLEncoder.encode(imageUri, "UTF-8")}") {
inclusive = true
}
}
}
},
enabled = canGoNext
) {
Icon(Icons.Default.KeyboardArrowRight, "Next")
}
}
}
)
}
Spacer(modifier = Modifier.height(16.dp))
tags.forEach { tag ->
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Zoomable image
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color.Black)
) {
Text(tag.value)
TextButton(onClick = { viewModel.removeTag(tag) }) {
Text("Remove")
val zoomState = rememberZoomState()
AsyncImage(
model = imageUri,
contentDescription = "Photo",
modifier = Modifier
.fillMaxSize()
.zoomable(zoomState),
contentScale = ContentScale.Fit
)
}
// Tags panel (slides up when enabled)
if (showTags) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 3.dp
) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Text(
"Tags (${tags.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
if (tags.isEmpty()) {
item {
Text(
"No tags yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(tags, key = { it.tagId }) { tag ->
TagCard(
tag = tag,
onRemove = { viewModel.removeTag(tag) }
)
}
}
}
}
}
}
}
@Composable
private fun TagCard(
tag: TagEntity,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when (tag.type) {
"PERSON" -> MaterialTheme.colorScheme.primaryContainer
"SYSTEM" -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.tertiaryContainer
}
),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (tag.type) {
"PERSON" -> Icons.Default.Face
"SYSTEM" -> Icons.Default.AutoAwesome
else -> Icons.Default.Label
},
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = when (tag.type) {
"PERSON" -> MaterialTheme.colorScheme.primary
"SYSTEM" -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.tertiary
}
)
Text(
text = tag.getDisplayValue(), // Uses TagEntity's built-in method
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = tag.type.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatTimestamp(tag.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Remove button (only for user-created tags)
if (tag.isUserTag()) {
IconButton(
onClick = onRemove,
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, "Remove tag")
}
}
}
}
}
/**
* Format timestamp to relative time
*/
private fun formatTimestamp(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
return when {
diff < 60_000 -> "Just now"
diff < 3600_000 -> "${diff / 60_000}m ago"
diff < 86400_000 -> "${diff / 3600_000}h ago"
diff < 604800_000 -> "${diff / 86400_000}d ago"
else -> "${diff / 604800_000}w ago"
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -14,6 +15,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.placeholder.sherpai2.ui.devscreens.DummyScreen
import com.placeholder.sherpai2.ui.album.AlbumViewScreen
import com.placeholder.sherpai2.ui.album.AlbumViewModel
import com.placeholder.sherpai2.ui.explore.ExploreScreen
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen
@@ -30,20 +32,12 @@ import java.net.URLDecoder
import java.net.URLEncoder
/**
* AppNavHost - Main navigation graph
* UPDATED: Added Explore and Tags screens
* AppNavHost - UPDATED with image list navigation
*
* Complete flow:
* - Photo browsing (Search, Explore, Detail)
* - Face recognition (Inventory, Train)
* - Organization (Tags, Upload)
* - Settings
*
* Features:
* - URL encoding for safe navigation
* - Proper back stack management
* - State preservation
* - Beautiful placeholders
* Changes:
* - Search/Album screens pass full image list to detail screen
* - Detail screen can navigate prev/next
* - Image URIs stored in SavedStateHandle for navigation
*/
@Composable
fun AppNavHost(
@@ -61,19 +55,27 @@ fun AppNavHost(
// ==========================================
/**
* SEARCH SCREEN
* Main photo browser with face tag search
* SEARCH SCREEN - UPDATED: Stores image list for navigation
*/
composable(AppRoutes.SEARCH) {
val searchViewModel: SearchViewModel = hiltViewModel()
val images by searchViewModel
.searchImages()
.collectAsStateWithLifecycle(initialValue = emptyList())
SearchScreen(
searchViewModel = searchViewModel,
onImageClick = { imageUri ->
// Store full image list for prev/next navigation
val allImageUris = images.map { it.image.imageUri }
navController.currentBackStackEntry
?.savedStateHandle
?.set("all_image_uris", allImageUris)
val encodedUri = URLEncoder.encode(imageUri, "UTF-8")
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri")
},
onAlbumClick = { tagValue ->
// Navigate to tag-based album
navController.navigate("album/tag/$tagValue")
}
)
@@ -81,7 +83,6 @@ fun AppNavHost(
/**
* EXPLORE SCREEN
* Browse smart albums (auto-generated from tags)
*/
composable(AppRoutes.EXPLORE) {
ExploreScreen(
@@ -92,8 +93,7 @@ fun AppNavHost(
}
/**
* IMAGE DETAIL SCREEN
* Single photo view with metadata
* IMAGE DETAIL SCREEN - UPDATED: Receives image list for navigation
*/
composable(
route = "${AppRoutes.IMAGE_DETAIL}/{imageUri}",
@@ -107,15 +107,22 @@ fun AppNavHost(
?.let { URLDecoder.decode(it, "UTF-8") }
?: error("imageUri missing from navigation")
// Get image list from previous screen
val allImageUris = navController.previousBackStackEntry
?.savedStateHandle
?.get<List<String>>("all_image_uris")
?: emptyList()
ImageDetailScreen(
imageUri = imageUri,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
navController = navController,
allImageUris = allImageUris
)
}
/**
* ALBUM VIEW SCREEN
* View photos in a specific album (tag, person, or time-based)
* ALBUM VIEW SCREEN - UPDATED: Stores image list for navigation
*/
composable(
route = "album/{albumType}/{albumId}",
@@ -128,11 +135,27 @@ fun AppNavHost(
}
)
) {
val albumViewModel: AlbumViewModel = hiltViewModel()
val uiState by albumViewModel.uiState.collectAsStateWithLifecycle()
AlbumViewScreen(
onBack = {
navController.popBackStack()
},
onImageClick = { imageUri ->
// Store full album image list
val allImageUris = if (uiState is com.placeholder.sherpai2.ui.album.AlbumUiState.Success) {
(uiState as com.placeholder.sherpai2.ui.album.AlbumUiState.Success)
.photos
.map { it.image.imageUri }
} else {
emptyList()
}
navController.currentBackStackEntry
?.savedStateHandle
?.set("all_image_uris", allImageUris)
val encodedUri = URLEncoder.encode(imageUri, "UTF-8")
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri")
}
@@ -145,19 +168,10 @@ fun AppNavHost(
/**
* PERSON INVENTORY SCREEN
* View all trained face models
*
* Features:
* - List all trained people
* - Show stats (training count, tagged photos, confidence)
* - Delete models
* - View photos containing each person
*/
composable(AppRoutes.INVENTORY) {
PersonInventoryScreen(
onViewPersonPhotos = { personId ->
// Navigate back to search
// TODO: In future, add person filter to search screen
navController.navigate(AppRoutes.SEARCH)
}
)
@@ -165,22 +179,13 @@ fun AppNavHost(
/**
* TRAINING FLOW
* Train new face recognition model
*
* Flow:
* 1. TrainingScreen (select images button)
* 2. ImageSelectorScreen (pick 15-50 photos)
* 3. ScanResultsScreen (validation + name input)
* 4. Training completes → navigate to Inventory
*/
composable(AppRoutes.TRAIN) { entry ->
val trainViewModel: TrainViewModel = hiltViewModel()
val uiState by trainViewModel.uiState.collectAsState()
// Get images selected from ImageSelector
val selectedUris = entry.savedStateHandle.get<List<Uri>>("selected_image_uris")
// Start scanning when new images are selected
LaunchedEffect(selectedUris) {
if (selectedUris != null && uiState is ScanningState.Idle) {
trainViewModel.scanAndTagFaces(selectedUris)
@@ -190,7 +195,6 @@ fun AppNavHost(
when (uiState) {
is ScanningState.Idle -> {
// Show start screen with "Select Images" button
TrainingScreen(
onSelectImages = {
navController.navigate(AppRoutes.IMAGE_SELECTOR)
@@ -198,11 +202,9 @@ fun AppNavHost(
)
}
else -> {
// Show validation results and training UI
ScanResultsScreen(
state = uiState,
onFinish = {
// After training, go to inventory to see new person
navController.navigate(AppRoutes.INVENTORY) {
popUpTo(AppRoutes.TRAIN) { inclusive = true }
}
@@ -214,12 +216,10 @@ fun AppNavHost(
/**
* IMAGE SELECTOR SCREEN
* Pick images for training (internal screen)
*/
composable(AppRoutes.IMAGE_SELECTOR) {
ImageSelectorScreen(
onImagesSelected = { uris ->
// Pass selected URIs back to Train screen
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_image_uris", uris)
@@ -230,7 +230,6 @@ fun AppNavHost(
/**
* MODELS SCREEN
* AI model management (placeholder)
*/
composable(AppRoutes.MODELS) {
DummyScreen(
@@ -245,7 +244,6 @@ fun AppNavHost(
/**
* TAGS SCREEN
* Manage photo tags with auto-tagging features
*/
composable(AppRoutes.TAGS) {
TagManagementScreen()
@@ -253,7 +251,6 @@ fun AppNavHost(
/**
* UTILITIES SCREEN
* Photo collection management tools
*/
composable(AppRoutes.UTILITIES) {
PhotoUtilitiesScreen()
@@ -265,7 +262,6 @@ fun AppNavHost(
/**
* SETTINGS SCREEN
* App preferences (placeholder)
*/
composable(AppRoutes.SETTINGS) {
DummyScreen(