diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e693ef..bad79bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,4 +85,7 @@ dependencies { // Gson for storing FloatArrays in Room implementation(libs.gson) + + // Zoomable + implementation(libs.zoomable) } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt index 97457aa..8aedfd2 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt @@ -1,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 -> { diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/imagedetail/ImageDetailScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/imagedetail/ImageDetailScreen.kt index b95bb24..5a6a2bf 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/imagedetail/ImageDetailScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/imagedetail/ImageDetailScreen.kt @@ -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 = 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt index 6f21dad..274b537 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt @@ -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>("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>("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( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9966887..49ae529 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,9 @@ tensorflow-lite = "2.14.0" tensorflow-lite-support = "0.4.4" gson = "2.10.1" +#Album/Image View Tools +zoomable = "1.6.1" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -68,6 +71,10 @@ tensorflow-lite-gpu = { group = "org.tensorflow", name = "tensorflow-lite-gpu", gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } + +#Album/Image View Tools +zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }