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.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
// Trigger ingestion once permission is granted
|
||||||
AppNavigation()
|
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 {
|
} 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.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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 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>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 2: auto-assign events by timestamp
|
val projection = arrayOf(
|
||||||
images.forEach { image ->
|
MediaStore.Images.Media._ID,
|
||||||
val events = eventDao.findEventsForTimestamp(image.capturedAt)
|
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 ->
|
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC"
|
||||||
imageEventDao.upsert(
|
|
||||||
ImageEventEntity(
|
context.contentResolver.query(
|
||||||
imageId = image.imageId,
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
eventId = event.eventId,
|
projection,
|
||||||
source = "AUTO",
|
null,
|
||||||
override = false
|
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<List<com.placeholder.sherpai2.data.local.model.ImageWithEverything>> {
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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(
|
||||||
|
|||||||
@@ -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
|
@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),
|
||||||
|
|||||||
@@ -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