Cleaner - UI ALmost and Room Photo Ingestion

This commit is contained in:
genki
2025-12-26 01:26:51 -05:00
parent 0f7f4a4201
commit 3f15bfabc1
18 changed files with 571 additions and 243 deletions

View File

@@ -5,8 +5,8 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -15,36 +15,27 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import com.placeholder.sherpai2.domain.repository.ImageRepository
import androidx.navigation.NavType import com.placeholder.sherpai2.ui.presentation.MainScreen
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.ui.theme.SherpAI2Theme import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/** import javax.inject.Inject
* Centralized string-based navigation routes.
*/
object Routes {
const val Search = "search"
const val ImageDetail = "image_detail"
}
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var imageRepository: ImageRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 Manifest.permission.READ_MEDIA_IMAGES
} else { } else {
@Suppress("DEPRECATION")
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
} }
@@ -52,72 +43,44 @@ class MainActivity : ComponentActivity() {
SherpAI2Theme { SherpAI2Theme {
var hasPermission by remember { var hasPermission by remember {
mutableStateOf( mutableStateOf(
ContextCompat.checkSelfPermission(this, mediaPermission) == ContextCompat.checkSelfPermission(this, storagePermission) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
) )
} }
// Track ingestion completion
var imagesIngested by remember { mutableStateOf(false) }
// Launcher for permission request
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted } ) { granted ->
hasPermission = granted
LaunchedEffect(Unit) {
if (!hasPermission) permissionLauncher.launch(mediaPermission)
} }
// Trigger ingestion once permission is granted
LaunchedEffect(hasPermission) {
if (hasPermission) { if (hasPermission) {
AppNavigation() // Suspend until ingestion completes
imageRepository.ingestImages()
imagesIngested = true
} else { } 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 // Gate UI until permission granted AND ingestion completed
composable( if (hasPermission && imagesIngested) {
route = "${Routes.ImageDetail}/{imageId}", MainScreen()
arguments = listOf(navArgument("imageId") { type = NavType.StringType }) } else {
) { backStackEntry ->
val imageId = backStackEntry.arguments?.getString("imageId") ?: ""
val detailViewModel: ImageDetailViewModel = hiltViewModel()
ImageDetailScreen(
imageUri = imageId,
onBack = { navController.popBackStack() }
)
}
}
}
@Composable
private fun PermissionDeniedScreen() {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("Please grant photo access to use the app.") Text("Please grant storage permission to continue.")
}
}
}
}
} }
} }

View File

@@ -4,7 +4,9 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.placeholder.sherpai2.data.local.entity.ImageEntity import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@@ -53,4 +55,14 @@ interface ImageDao {
start: Long, start: Long,
end: Long end: Long
): List<ImageEntity> ): 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)
} }

View File

@@ -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
)

View File

@@ -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())
}

View File

@@ -1,8 +1,9 @@
package com.placeholder.sherpai2.di package com.placeholder.sherpai2.di
import com.placeholder.sherpai2.data.repository.ImageRepositoryImpl
import com.placeholder.sherpai2.data.repository.TaggingRepositoryImpl import com.placeholder.sherpai2.data.repository.TaggingRepositoryImpl
import com.placeholder.sherpai2.domain.repository.ImageRepository import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.domain.repository.ImageRepositoryImpl
import com.placeholder.sherpai2.domain.repository.TaggingRepository import com.placeholder.sherpai2.domain.repository.TaggingRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module

View File

@@ -1,3 +0,0 @@
package com.placeholder.sherpai2.domain
//fun getAllPhotos(context: Context): List<Photo> {

View File

@@ -1,7 +0,0 @@
package com.placeholder.sherpai2.domain
import android.content.Context
class PhotoDuplicateScanner(private val context: Context) {
}

View File

@@ -28,4 +28,6 @@ interface ImageRepository {
fun getAllImages(): Flow<List<ImageWithEverything>> fun getAllImages(): Flow<List<ImageWithEverything>>
fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>> fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>>
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
} }

View File

@@ -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.EventDao
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
import com.placeholder.sherpai2.data.local.dao.ImageDao import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageEventDao import com.placeholder.sherpai2.data.local.dao.ImageEventDao
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.domain.repository.ImageRepository 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.flow.Flow
import kotlinx.coroutines.withContext
import java.security.MessageDigest
import java.util.*
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -15,50 +25,123 @@ class ImageRepositoryImpl @Inject constructor(
private val imageDao: ImageDao, private val imageDao: ImageDao,
private val eventDao: EventDao, private val eventDao: EventDao,
private val imageEventDao: ImageEventDao, private val imageEventDao: ImageEventDao,
private val aggregateDao: ImageAggregateDao private val aggregateDao: ImageAggregateDao,
@ApplicationContext private val context: Context
) : ImageRepository { ) : 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) return aggregateDao.observeImageWithEverything(imageId)
} }
/** /**
* Ingest images from device. * Ingest all images from MediaStore.
* * Uses _ID and DATE_ADDED to ensure no image is skipped, even if DATE_TAKEN is identical.
* NOTE:
* Actual MediaStore scanning is deliberately omitted here.
* This function assumes images already exist in ImageDao.
*/ */
override suspend fun ingestImages() { override suspend fun ingestImages(): Unit = withContext(Dispatchers.IO) {
// Step 1: fetch all images try {
val images = imageDao.getImagesInRange( val imageList = mutableListOf<ImageEntity>()
start = 0L,
end = Long.MAX_VALUE 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 val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC"
images.forEach { image ->
val events = eventDao.findEventsForTimestamp(image.capturedAt)
events.forEach { event -> context.contentResolver.query(
imageEventDao.upsert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
ImageEventEntity( projection,
imageId = image.imageId, null,
eventId = event.eventId, null,
source = "AUTO", sortOrder
override = false )?.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() return aggregateDao.observeAllImagesWithEverything()
} }
override fun findImagesByTag(tag: String): Flow<List<com.placeholder.sherpai2.data.local.model.ImageWithEverything>> { override fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>> {
// Assuming aggregateDao can filter images by tag
return aggregateDao.observeImagesWithTag(tag) return aggregateDao.observeImagesWithTag(tag)
} }
override fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>> {
return imageDao.getRecentImages(limit)
}
} }

View File

@@ -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)
}
}

View File

@@ -5,76 +5,39 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
/** /**
* AppDestinations * Drawer-only metadata.
* *
* Centralized definition of all top-level screens. * These objects:
* This avoids stringly-typed navigation. * - Drive the drawer UI
* - Provide labels and icons
* - Map cleanly to navigation routes
*/ */
sealed class AppDestinations( sealed class AppDestinations(
val route: String, val route: String,
val icon: ImageVector, val icon: ImageVector,
val label: String 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( object ImageDetails : AppDestinations(AppRoutes.IMAGE_DETAIL, Icons.Default.LocalOffer, "IMAGE_DETAIL")
route = "tour",
icon = Icons.Default.PhotoLibrary,
label = "Tour"
)
object Search : AppDestinations( object Upload : AppDestinations(AppRoutes.UPLOAD, Icons.Default.CloudUpload, "Upload")
route = "search", object Settings : AppDestinations(AppRoutes.SETTINGS, Icons.Default.Settings, "Settings")
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"
)
} }
/**
* Drawer grouping
*/
val mainDrawerItems = listOf( val mainDrawerItems = listOf(
AppDestinations.Tour, AppDestinations.Tour,
AppDestinations.Search, AppDestinations.Search,
AppDestinations.Models, AppDestinations.Models,
AppDestinations.Inventory, AppDestinations.Inventory,
AppDestinations.Train, AppDestinations.Train,
AppDestinations.Tags AppDestinations.Tags,
AppDestinations.ImageDetails
) )
val utilityDrawerItems = listOf( val utilityDrawerItems = listOf(

View File

@@ -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)") }
}
}

View File

@@ -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"
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -16,7 +16,9 @@ import com.placeholder.sherpai2.data.local.entity.ImageEntity
*/ */
@Composable @Composable
fun ImageGridItem( fun ImageGridItem(
image: ImageEntity image: ImageEntity,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
) { ) {
Image( Image(
painter = rememberAsyncImagePainter(image.imageUri), painter = rememberAsyncImagePainter(image.imageUri),

View File

@@ -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)
)
}
}

View File

@@ -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
}
}
}
}