Cleaner - UI ALmost and Room Photo Ingestion
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
// Trigger ingestion once permission is granted
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) {
|
||||
AppNavigation()
|
||||
// Suspend until ingestion completes
|
||||
imageRepository.ingestImages()
|
||||
imagesIngested = true
|
||||
} else {
|
||||
PermissionDeniedScreen()
|
||||
permissionLauncher.launch(storagePermission)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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() {
|
||||
// Gate UI until permission granted AND ingestion completed
|
||||
if (hasPermission && imagesIngested) {
|
||||
MainScreen()
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Please grant photo access to use the app.")
|
||||
Text("Please grant storage permission to continue.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImageEntity>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM images ORDER BY capturedAt DESC LIMIT :limit")
|
||||
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
|
||||
|
||||
@Query("SELECT COUNT(*) > 0 FROM images WHERE sha256 = :sha256")
|
||||
suspend fun existsBySha256(sha256: String): Boolean
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insert(image: ImageEntity)
|
||||
}
|
||||
|
||||
@@ -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<Photo>
|
||||
)
|
||||
|
||||
data class AlbumPhoto(
|
||||
val id: Int,
|
||||
val imageUrl: String,
|
||||
val description: String
|
||||
)
|
||||
@@ -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<List<Photo>> {
|
||||
// Best Practice: Use Environment.getExternalStorageDirectory()
|
||||
// only as a fallback or starting point for legacy support.
|
||||
val rootPath = Environment.getExternalStorageDirectory()
|
||||
|
||||
return runCatching {
|
||||
val photos = mutableListOf<Photo>()
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.placeholder.sherpai2.domain
|
||||
|
||||
//fun getAllPhotos(context: Context): List<Photo> {
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.placeholder.sherpai2.domain
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class PhotoDuplicateScanner(private val context: Context) {
|
||||
|
||||
}
|
||||
@@ -28,4 +28,6 @@ interface ImageRepository {
|
||||
|
||||
fun getAllImages(): Flow<List<ImageWithEverything>>
|
||||
fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>>
|
||||
|
||||
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
|
||||
}
|
||||
|
||||
@@ -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<com.placeholder.sherpai2.data.local.model.ImageWithEverything> {
|
||||
override fun observeImage(imageId: String): Flow<ImageWithEverything> {
|
||||
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<ImageEntity>()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// Step 2: auto-assign events by timestamp
|
||||
images.forEach { image ->
|
||||
val events = eventDao.findEventsForTimestamp(image.capturedAt)
|
||||
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC"
|
||||
|
||||
events.forEach { event ->
|
||||
imageEventDao.upsert(
|
||||
ImageEventEntity(
|
||||
imageId = image.imageId,
|
||||
eventId = event.eventId,
|
||||
source = "AUTO",
|
||||
override = false
|
||||
)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllImages(): Flow<List<com.placeholder.sherpai2.data.local.model.ImageWithEverything>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<List<ImageWithEverything>> {
|
||||
return aggregateDao.observeAllImagesWithEverything()
|
||||
}
|
||||
|
||||
override fun findImagesByTag(tag: String): Flow<List<com.placeholder.sherpai2.data.local.model.ImageWithEverything>> {
|
||||
// Assuming aggregateDao can filter images by tag
|
||||
override fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>> {
|
||||
return aggregateDao.observeImagesWithTag(tag)
|
||||
}
|
||||
|
||||
override fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>> {
|
||||
return imageDao.getRecentImages(limit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)") }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<List<ImageWithEverything>>(emptyList())
|
||||
val recentImages: StateFlow<List<ImageWithEverything>> = _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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user