From 3f15bfabc1c8f3182f65d21d67c7ce4d6d3e4640 Mon Sep 17 00:00:00 2001 From: genki <123@1234.com> Date: Fri, 26 Dec 2025 01:26:51 -0500 Subject: [PATCH] Cleaner - UI ALmost and Room Photo Ingestion --- .../com/placeholder/sherpai2/MainActivity.kt | 113 +++++--------- .../sherpai2/data/local/dao/ImageDao.kt | 12 ++ .../placeholder/sherpai2/data/photos/Photo.kt | 23 --- .../sherpai2/data/repo/PhotoRepository.kt | 50 ------ .../sherpai2/di/RepositoryModule.kt | 3 +- .../sherpai2/domain/PhotoFunctions.kt | 3 - .../sherpai2/domain/PhotoScanner.kt | 7 - .../domain/repository/ImageRepository.kt | 2 + .../domain/repository/ImageRepositoryImpl.kt | 143 ++++++++++++++---- .../sherpai2/ui/devscreens/DummyScreen.kt | 17 +++ .../sherpai2/ui/navigation/AppDestinations.kt | 69 ++------- .../sherpai2/ui/navigation/AppNavHost.kt | 83 ++++++++++ .../sherpai2/ui/navigation/AppRoutes.kt | 23 +++ .../ui/presentation/AppDrawerContent.kt | 78 ++++++++++ .../sherpai2/ui/presentation/MainScreen.kt | 68 +++++++++ .../ui/search/components/ImageGridItem.kt | 4 +- .../sherpai2/ui/tour/TourScreen.kt | 77 ++++++++++ .../sherpai2/ui/tour/TourViewModel.kt | 39 +++++ 18 files changed, 571 insertions(+), 243 deletions(-) delete mode 100644 app/src/main/java/com/placeholder/sherpai2/data/photos/Photo.kt delete mode 100644 app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt delete mode 100644 app/src/main/java/com/placeholder/sherpai2/domain/PhotoFunctions.kt delete mode 100644 app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/devscreens/DummyScreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/tour/TourScreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/tour/TourViewModel.kt diff --git a/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt b/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt index ff473e0..5152f46 100644 --- a/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt +++ b/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt @@ -5,8 +5,8 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -15,36 +15,27 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen -import com.placeholder.sherpai2.ui.imagedetail.viewmodel.ImageDetailViewModel -import com.placeholder.sherpai2.ui.search.SearchScreen -import com.placeholder.sherpai2.ui.search.SearchViewModel +import com.placeholder.sherpai2.domain.repository.ImageRepository +import com.placeholder.sherpai2.ui.presentation.MainScreen import com.placeholder.sherpai2.ui.theme.SherpAI2Theme import dagger.hilt.android.AndroidEntryPoint - -/** - * Centralized string-based navigation routes. - */ -object Routes { - const val Search = "search" - const val ImageDetail = "image_detail" -} +import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var imageRepository: ImageRepository + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val mediaPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Determine storage permission based on Android version + val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { + @Suppress("DEPRECATION") Manifest.permission.READ_EXTERNAL_STORAGE } @@ -52,72 +43,44 @@ class MainActivity : ComponentActivity() { SherpAI2Theme { var hasPermission by remember { mutableStateOf( - ContextCompat.checkSelfPermission(this, mediaPermission) == + ContextCompat.checkSelfPermission(this, storagePermission) == PackageManager.PERMISSION_GRANTED ) } + // Track ingestion completion + var imagesIngested by remember { mutableStateOf(false) } + + // Launcher for permission request val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() - ) { granted -> hasPermission = granted } - - LaunchedEffect(Unit) { - if (!hasPermission) permissionLauncher.launch(mediaPermission) + ) { granted -> + hasPermission = granted } - if (hasPermission) { - AppNavigation() + // Trigger ingestion once permission is granted + LaunchedEffect(hasPermission) { + if (hasPermission) { + // Suspend until ingestion completes + imageRepository.ingestImages() + imagesIngested = true + } else { + permissionLauncher.launch(storagePermission) + } + } + + // Gate UI until permission granted AND ingestion completed + if (hasPermission && imagesIngested) { + MainScreen() } else { - PermissionDeniedScreen() + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Please grant storage permission to continue.") + } } } } } } - -@Composable -fun AppNavigation() { - val navController = rememberNavController() - - NavHost( - navController = navController, - startDestination = Routes.Search - ) { - - // Search screen - composable(Routes.Search) { - val searchViewModel: SearchViewModel = hiltViewModel() - - SearchScreen( - searchViewModel = searchViewModel, - onImageClick = { imageId -> - navController.navigate("${Routes.ImageDetail}/$imageId") - } - ) - } - - // Image detail screen - composable( - route = "${Routes.ImageDetail}/{imageId}", - arguments = listOf(navArgument("imageId") { type = NavType.StringType }) - ) { backStackEntry -> - val imageId = backStackEntry.arguments?.getString("imageId") ?: "" - val detailViewModel: ImageDetailViewModel = hiltViewModel() - - ImageDetailScreen( - imageUri = imageId, - onBack = { navController.popBackStack() } - ) - } - } -} - -@Composable -private fun PermissionDeniedScreen() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text("Please grant photo access to use the app.") - } -} diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt index e85e84c..6589f0f 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt @@ -4,7 +4,9 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.placeholder.sherpai2.data.local.entity.ImageEntity +import com.placeholder.sherpai2.data.local.model.ImageWithEverything import kotlinx.coroutines.flow.Flow @Dao @@ -53,4 +55,14 @@ interface ImageDao { start: Long, end: Long ): List + + @Transaction + @Query("SELECT * FROM images ORDER BY capturedAt DESC LIMIT :limit") + fun getRecentImages(limit: Int): Flow> + + @Query("SELECT COUNT(*) > 0 FROM images WHERE sha256 = :sha256") + suspend fun existsBySha256(sha256: String): Boolean + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(image: ImageEntity) } diff --git a/app/src/main/java/com/placeholder/sherpai2/data/photos/Photo.kt b/app/src/main/java/com/placeholder/sherpai2/data/photos/Photo.kt deleted file mode 100644 index 7370424..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/data/photos/Photo.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.placeholder.sherpai2.data.photos - -import android.net.Uri - -data class Photo( - val id: Long, - val uri: Uri, - val title: String? = null, - val size: Long, - val dateModified: Int -) - -data class Album( - val id: String, - val title: String, - val photos: List -) - -data class AlbumPhoto( - val id: Int, - val imageUrl: String, - val description: String -) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt b/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt deleted file mode 100644 index d6286cb..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.placeholder.sherpai2.data.repo - -import android.content.Context -import android.provider.MediaStore -import android.content.ContentUris -import android.net.Uri -import android.os.Environment -import android.util.Log -import com.placeholder.sherpai2.data.photos.Photo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File - -class PhotoRepository(private val context: Context) { - - fun scanExternalStorage(): Result> { - // Best Practice: Use Environment.getExternalStorageDirectory() - // only as a fallback or starting point for legacy support. - val rootPath = Environment.getExternalStorageDirectory() - - return runCatching { - val photos = mutableListOf() - - if (rootPath.exists() && rootPath.isDirectory) { - // walkTopDown is efficient but can throw AccessDeniedException - rootPath.walkTopDown() - .maxDepth(3) // Performance Best Practice: Don't scan the whole phone - .filter { it.isFile && isImageFile(it.extension) } - .forEach { file -> - photos.add(mapFileToPhoto(file)) - } - } - photos - }.onFailure { e -> - Log.e("PhotoRepo", "Failed to scan filesystem", e) - } - } - - private fun mapFileToPhoto(file: File): Photo { - return Photo( - id = file.path.hashCode().toLong(), - uri = Uri.fromFile(file), - title = file.name, - size = file.length(), - dateModified = (file.lastModified() / 1000).toInt() - ) - } - - private fun isImageFile(ext: String) = listOf("jpg", "jpeg", "png").contains(ext.lowercase()) -} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/di/RepositoryModule.kt b/app/src/main/java/com/placeholder/sherpai2/di/RepositoryModule.kt index 7c9f85a..1cb8bfb 100644 --- a/app/src/main/java/com/placeholder/sherpai2/di/RepositoryModule.kt +++ b/app/src/main/java/com/placeholder/sherpai2/di/RepositoryModule.kt @@ -1,8 +1,9 @@ package com.placeholder.sherpai2.di -import com.placeholder.sherpai2.data.repository.ImageRepositoryImpl + import com.placeholder.sherpai2.data.repository.TaggingRepositoryImpl import com.placeholder.sherpai2.domain.repository.ImageRepository +import com.placeholder.sherpai2.domain.repository.ImageRepositoryImpl import com.placeholder.sherpai2.domain.repository.TaggingRepository import dagger.Binds import dagger.Module diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/PhotoFunctions.kt b/app/src/main/java/com/placeholder/sherpai2/domain/PhotoFunctions.kt deleted file mode 100644 index a31556f..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/domain/PhotoFunctions.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.placeholder.sherpai2.domain - -//fun getAllPhotos(context: Context): List { diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt b/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt deleted file mode 100644 index 18bf55b..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.placeholder.sherpai2.domain - -import android.content.Context - -class PhotoDuplicateScanner(private val context: Context) { - -} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepository.kt b/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepository.kt index ce5f28f..5f479c2 100644 --- a/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepository.kt +++ b/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepository.kt @@ -28,4 +28,6 @@ interface ImageRepository { fun getAllImages(): Flow> fun findImagesByTag(tag: String): Flow> + + fun getRecentImages(limit: Int): Flow> } diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepositoryImpl.kt b/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepositoryImpl.kt index 2d360f5..9167d0a 100644 --- a/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepositoryImpl.kt +++ b/app/src/main/java/com/placeholder/sherpai2/domain/repository/ImageRepositoryImpl.kt @@ -1,12 +1,22 @@ -package com.placeholder.sherpai2.data.repository +package com.placeholder.sherpai2.domain.repository +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import android.util.Log import com.placeholder.sherpai2.data.local.dao.EventDao import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao import com.placeholder.sherpai2.data.local.dao.ImageDao import com.placeholder.sherpai2.data.local.dao.ImageEventDao -import com.placeholder.sherpai2.data.local.entity.ImageEventEntity -import com.placeholder.sherpai2.domain.repository.ImageRepository +import com.placeholder.sherpai2.data.local.entity.ImageEntity +import com.placeholder.sherpai2.data.local.model.ImageWithEverything +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import java.security.MessageDigest +import java.util.* import javax.inject.Inject import javax.inject.Singleton @@ -15,50 +25,123 @@ class ImageRepositoryImpl @Inject constructor( private val imageDao: ImageDao, private val eventDao: EventDao, private val imageEventDao: ImageEventDao, - private val aggregateDao: ImageAggregateDao + private val aggregateDao: ImageAggregateDao, + @ApplicationContext private val context: Context ) : ImageRepository { - override fun observeImage(imageId: String): Flow { + override fun observeImage(imageId: String): Flow { return aggregateDao.observeImageWithEverything(imageId) } /** - * Ingest images from device. - * - * NOTE: - * Actual MediaStore scanning is deliberately omitted here. - * This function assumes images already exist in ImageDao. + * Ingest all images from MediaStore. + * Uses _ID and DATE_ADDED to ensure no image is skipped, even if DATE_TAKEN is identical. */ - override suspend fun ingestImages() { - // Step 1: fetch all images - val images = imageDao.getImagesInRange( - start = 0L, - end = Long.MAX_VALUE - ) + override suspend fun ingestImages(): Unit = withContext(Dispatchers.IO) { + try { + val imageList = mutableListOf() - // Step 2: auto-assign events by timestamp - images.forEach { image -> - val events = eventDao.findEventsForTimestamp(image.capturedAt) + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.DATE_ADDED, + MediaStore.Images.Media.WIDTH, + MediaStore.Images.Media.HEIGHT + ) - events.forEach { event -> - imageEventDao.upsert( - ImageEventEntity( - imageId = image.imageId, - eventId = event.eventId, - source = "AUTO", - override = false + val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC" + + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + null, + null, + sortOrder + )?.use { cursor -> + + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + val dateTakenCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN) + val dateAddedCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED) + val widthCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH) + val heightCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + val displayName = cursor.getString(nameCol) + val dateTaken = cursor.getLong(dateTakenCol) + val dateAdded = cursor.getLong(dateAddedCol) + val width = cursor.getInt(widthCol) + val height = cursor.getInt(heightCol) + + val contentUri: Uri = ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) - ) + + val sha256 = computeSHA256(contentUri) + if (sha256 == null) { + Log.w("ImageRepository", "Skipped image: $displayName (cannot read bytes)") + continue + } + + val imageEntity = ImageEntity( + imageId = UUID.randomUUID().toString(), + imageUri = contentUri.toString(), + sha256 = sha256, + capturedAt = if (dateTaken > 0) dateTaken else dateAdded * 1000, + ingestedAt = System.currentTimeMillis(), + width = width, + height = height, + source = "CAMERA" // or SCREENSHOT / IMPORTED + ) + + imageList += imageEntity + Log.i("ImageRepository", "Processing image: $displayName, SHA256: $sha256") + } } + + if (imageList.isNotEmpty()) { + imageDao.insertImages(imageList) + Log.i("ImageRepository", "Ingested ${imageList.size} images") + } else { + Log.i("ImageRepository", "No images found on device") + } + + } catch (e: Exception) { + Log.e("ImageRepository", "Error ingesting images", e) } } - override fun getAllImages(): Flow> { + /** + * Compute SHA256 from a MediaStore Uri safely. + */ + private fun computeSHA256(uri: Uri): String? { + return try { + val digest = MessageDigest.getInstance("SHA-256") + context.contentResolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(8192) + var read: Int + while (input.read(buffer).also { read = it } > 0) { + digest.update(buffer, 0, read) + } + } ?: return null + digest.digest().joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + Log.e("ImageRepository", "Failed SHA256 for $uri", e) + null + } + } + + override fun getAllImages(): Flow> { return aggregateDao.observeAllImagesWithEverything() } - override fun findImagesByTag(tag: String): Flow> { - // Assuming aggregateDao can filter images by tag + override fun findImagesByTag(tag: String): Flow> { return aggregateDao.observeImagesWithTag(tag) } + + override fun getRecentImages(limit: Int): Flow> { + return imageDao.getRecentImages(limit) + } } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/devscreens/DummyScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/devscreens/DummyScreen.kt new file mode 100644 index 0000000..c453471 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/devscreens/DummyScreen.kt @@ -0,0 +1,17 @@ +package com.placeholder.sherpai2.ui.devscreens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun DummyScreen(label: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(label) + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt index 2560ffa..57305ce 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt @@ -5,76 +5,39 @@ import androidx.compose.material.icons.filled.* import androidx.compose.ui.graphics.vector.ImageVector /** - * AppDestinations + * Drawer-only metadata. * - * Centralized definition of all top-level screens. - * This avoids stringly-typed navigation. + * These objects: + * - Drive the drawer UI + * - Provide labels and icons + * - Map cleanly to navigation routes */ sealed class AppDestinations( val route: String, val icon: ImageVector, val label: String ) { + object Tour : AppDestinations(AppRoutes.TOUR, Icons.Default.PhotoLibrary, "Tour") + object Search : AppDestinations(AppRoutes.SEARCH, Icons.Default.Search, "Search") + object Models : AppDestinations(AppRoutes.MODELS, Icons.Default.Layers, "Models") + object Inventory : AppDestinations(AppRoutes.INVENTORY, Icons.Default.Inventory2, "Inv") + object Train : AppDestinations(AppRoutes.TRAIN, Icons.Default.TrackChanges, "Train") + object Tags : AppDestinations(AppRoutes.TAGS, Icons.Default.LocalOffer, "Tags") - object Tour : AppDestinations( - route = "tour", - icon = Icons.Default.PhotoLibrary, - label = "Tour" - ) + object ImageDetails : AppDestinations(AppRoutes.IMAGE_DETAIL, Icons.Default.LocalOffer, "IMAGE_DETAIL") - object Search : AppDestinations( - route = "search", - icon = Icons.Default.Search, - label = "Search" - ) - - object Models : AppDestinations( - route = "models", - icon = Icons.Default.Layers, - label = "Models" - ) - - object Inventory : AppDestinations( - route = "inventory", - icon = Icons.Default.Inventory2, - label = "Inventory" - ) - - object Train : AppDestinations( - route = "train", - icon = Icons.Default.TrackChanges, - label = "Train" - ) - - object Tags : AppDestinations( - route = "tags", - icon = Icons.Default.LocalOffer, - label = "Tags" - ) - - object Upload : AppDestinations( - route = "upload", - icon = Icons.Default.CloudUpload, - label = "Upload" - ) - - object Settings : AppDestinations( - route = "settings", - icon = Icons.Default.Settings, - label = "Settings" - ) + object Upload : AppDestinations(AppRoutes.UPLOAD, Icons.Default.CloudUpload, "Upload") + object Settings : AppDestinations(AppRoutes.SETTINGS, Icons.Default.Settings, "Settings") } -/** - * Drawer grouping - */ val mainDrawerItems = listOf( AppDestinations.Tour, AppDestinations.Search, AppDestinations.Models, AppDestinations.Inventory, AppDestinations.Train, - AppDestinations.Tags + AppDestinations.Tags, + AppDestinations.ImageDetails ) val utilityDrawerItems = listOf( 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 new file mode 100644 index 0000000..7e5a0f3 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt @@ -0,0 +1,83 @@ +package com.placeholder.sherpai2.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.placeholder.sherpai2.ui.devscreens.DummyScreen +import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen +import com.placeholder.sherpai2.ui.search.SearchScreen +import com.placeholder.sherpai2.ui.search.SearchViewModel +import java.net.URLDecoder +import java.net.URLEncoder +import com.placeholder.sherpai2.ui.tour.TourViewModel +import com.placeholder.sherpai2.ui.tour.TourScreen +@Composable +fun AppNavHost( + navController: NavHostController, + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = AppRoutes.SEARCH, + modifier = modifier + ) { + + /** SEARCH SCREEN **/ + composable(AppRoutes.SEARCH) { + val searchViewModel: SearchViewModel = hiltViewModel() + SearchScreen( + searchViewModel = searchViewModel, + onImageClick = { imageUri -> + // Encode the URI to safely pass as argument + val encodedUri = URLEncoder.encode(imageUri, "UTF-8") + navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri") + } + ) + } + + /** IMAGE DETAIL SCREEN **/ + composable( + route = "${AppRoutes.IMAGE_DETAIL}/{imageUri}", + arguments = listOf( + navArgument("imageUri") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + + // Decode URI to restore original value + val imageUri = backStackEntry.arguments?.getString("imageUri") + ?.let { URLDecoder.decode(it, "UTF-8") } + ?: error("imageUri missing from navigation") + + ImageDetailScreen( + imageUri = imageUri, + onBack = { navController.popBackStack() } + ) + } + + composable(AppRoutes.TOUR) { + val tourViewModel: TourViewModel = hiltViewModel() + TourScreen( + tourViewModel = tourViewModel, + onImageClick = { imageUri -> + navController.navigate("${AppRoutes.IMAGE_DETAIL}/$imageUri") + } + ) + } + + /** DUMMY SCREENS FOR OTHER DRAWER ITEMS **/ + //composable(AppRoutes.TOUR) { DummyScreen("Tour (stub)") } + composable(AppRoutes.MODELS) { DummyScreen("Models (stub)") } + composable(AppRoutes.INVENTORY) { DummyScreen("Inventory (stub)") } + composable(AppRoutes.TRAIN) { DummyScreen("Train (stub)") } + composable(AppRoutes.TAGS) { DummyScreen("Tags (stub)") } + composable(AppRoutes.UPLOAD) { DummyScreen("Upload (stub)") } + composable(AppRoutes.SETTINGS) { DummyScreen("Settings (stub)") } + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt new file mode 100644 index 0000000..295475f --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt @@ -0,0 +1,23 @@ +package com.placeholder.sherpai2.ui.navigation + +/** + * Centralized list of navigation routes used by NavHost. + * + * This intentionally mirrors AppDestinations.route + * but exists as a pure navigation concern. + * + * Why: + * - Drawer UI ≠ Navigation system + * - Keeps NavHost decoupled from icons / labels + */ +object AppRoutes { + const val TOUR = "tour" + const val SEARCH = "search" + const val MODELS = "models" + const val INVENTORY = "inv" + const val TRAIN = "train" + const val TAGS = "tags" + const val UPLOAD = "upload" + const val SETTINGS = "settings" + const val IMAGE_DETAIL = "IMAGE_DETAIL" +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt new file mode 100644 index 0000000..86c177d --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt @@ -0,0 +1,78 @@ +package com.placeholder.sherpai2.ui.presentation + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.material3.DividerDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import com.placeholder.sherpai2.ui.navigation.AppRoutes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppDrawerContent( + currentRoute: String?, + onDestinationClicked: (String) -> Unit +) { + // Drawer sheet with fixed width + ModalDrawerSheet(modifier = Modifier.width(280.dp)) { + + // Header / Logo + Text( + "SherpAI Control Panel", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(16.dp) + ) + + Divider(Modifier.fillMaxWidth(), thickness = DividerDefaults.Thickness) + + // Main drawer items + val mainItems = listOf( + Triple(AppRoutes.SEARCH, "Search", Icons.Default.Search), + Triple(AppRoutes.TOUR, "Tour", Icons.Default.Place), + Triple(AppRoutes.MODELS, "Models", Icons.Default.ModelTraining), + Triple(AppRoutes.INVENTORY, "Inventory", Icons.Default.List), + Triple(AppRoutes.TRAIN, "Train", Icons.Default.Train), + Triple(AppRoutes.TAGS, "Tags", Icons.Default.Label) + ) + + Column(modifier = Modifier.padding(vertical = 8.dp)) { + mainItems.forEach { (route, label, icon) -> + NavigationDrawerItem( + label = { Text(label) }, + icon = { Icon(icon, contentDescription = label) }, + selected = route == currentRoute, + onClick = { onDestinationClicked(route) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + + Divider( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + thickness = DividerDefaults.Thickness + ) + + // Utility items + val utilityItems = listOf( + Triple(AppRoutes.UPLOAD, "Upload", Icons.Default.UploadFile), + Triple(AppRoutes.SETTINGS, "Settings", Icons.Default.Settings) + ) + + Column(modifier = Modifier.padding(vertical = 8.dp)) { + utilityItems.forEach { (route, label, icon) -> + NavigationDrawerItem( + label = { Text(label) }, + icon = { Icon(icon, contentDescription = label) }, + selected = route == currentRoute, + onClick = { onDestinationClicked(route) }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + } + } + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt new file mode 100644 index 0000000..051005f --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt @@ -0,0 +1,68 @@ +package com.placeholder.sherpai2.ui.presentation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.placeholder.sherpai2.ui.navigation.AppNavHost +import com.placeholder.sherpai2.ui.navigation.AppRoutes +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen() { + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + + // Navigation controller for NavHost + val navController = rememberNavController() + + // Track current backstack entry to update top bar title dynamically + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route ?: AppRoutes.SEARCH + + // Drawer content for navigation + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + AppDrawerContent( + currentRoute = currentRoute, + onDestinationClicked = { route -> + scope.launch { + drawerState.close() + if (route != currentRoute) { + navController.navigate(route) { + // Avoid multiple copies of the same destination + launchSingleTop = true + } + } + } + } + ) + }, + ) { + // Main scaffold with top bar + Scaffold( + topBar = { + TopAppBar( + title = { Text(currentRoute.replaceFirstChar { it.uppercase() }) }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Filled.Menu, contentDescription = "Open Drawer") + } + } + ) + } + ) { paddingValues -> + AppNavHost( + navController = navController, + modifier = Modifier.padding(paddingValues) + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/search/components/ImageGridItem.kt b/app/src/main/java/com/placeholder/sherpai2/ui/search/components/ImageGridItem.kt index e877a3e..51c68ff 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/search/components/ImageGridItem.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/search/components/ImageGridItem.kt @@ -16,7 +16,9 @@ import com.placeholder.sherpai2.data.local.entity.ImageEntity */ @Composable fun ImageGridItem( - image: ImageEntity + image: ImageEntity, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null ) { Image( painter = rememberAsyncImagePainter(image.imageUri), diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tour/TourScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/tour/TourScreen.kt new file mode 100644 index 0000000..0ad285f --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/tour/TourScreen.kt @@ -0,0 +1,77 @@ +// TourScreen.kt +package com.placeholder.sherpai2.ui.tour + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.placeholder.sherpai2.data.local.model.ImageWithEverything + +@Composable +fun TourScreen(viewModel: TourViewModel = hiltViewModel()) { + val images by viewModel.recentImages.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + // Header with image count + Text( + text = "Gallery (${images.size} images)", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp) + ) { + items(images) { image -> + ImageCard(image) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +} + +@Composable +fun ImageCard(image: ImageWithEverything) { + Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(4.dp)) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = image.imageUri, style = MaterialTheme.typography.bodyMedium) + + // Tags row with placeholders if fewer than 3 + Row(modifier = Modifier.padding(top = 8.dp)) { + val tags = image.tags.map { it.name } // adjust depending on your entity + tags.forEach { tag -> + TagComposable(tag) + } + repeat(3 - tags.size.coerceAtMost(3)) { + TagComposable("") // empty placeholder + } + } + } + } +} + +@Composable +fun TagComposable(tag: String) { + Box( + modifier = Modifier + .padding(end = 4.dp) + .height(24.dp) + .widthIn(min = 40.dp) + .background(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.shapes.small), + contentAlignment = androidx.compose.ui.Alignment.Center + ) { + Text( + text = if (tag.isNotBlank()) tag else " ", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp) + ) + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tour/TourViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/tour/TourViewModel.kt new file mode 100644 index 0000000..677ccb5 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/tour/TourViewModel.kt @@ -0,0 +1,39 @@ +// TourViewModel.kt +package com.placeholder.sherpai2.ui.tour + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.domain.repository.ImageRepository +import com.placeholder.sherpai2.data.local.model.ImageWithEverything +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TourViewModel @Inject constructor( + private val imageRepository: ImageRepository +) : ViewModel() { + + // Expose recent images as StateFlow + private val _recentImages = MutableStateFlow>(emptyList()) + val recentImages: StateFlow> = _recentImages.asStateFlow() + + init { + loadRecentImages() + } + + private fun loadRecentImages(limit: Int = 100) { + viewModelScope.launch { + imageRepository.getRecentImages(limit) + .catch { e -> + println("TourViewModel: error fetching images: $e") + _recentImages.value = emptyList() + } + .collect { images -> + println("TourViewModel: fetched ${images.size} images") + _recentImages.value = images + } + } + } +}