Compare commits
2 Commits
c458e08075
...
0f7f4a4201
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7f4a4201 | ||
|
|
0d34a2510b |
@@ -12,7 +12,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.placeholder.sherpai2"
|
||||
minSdk = 24
|
||||
minSdk = 25
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
@@ -42,35 +42,34 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX & Lifecycle
|
||||
// Core & Lifecycle
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Compose (Using BOM)
|
||||
// Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
|
||||
// Navigation & Hilt Integration
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
// Images
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Tooling (Fixed to use TOML alias)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
//backend2
|
||||
//room addon
|
||||
// Hilt DI
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Room Database
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// Coil Images
|
||||
implementation(libs.coil.compose)
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SherpAI2">
|
||||
android:theme="@style/Theme.SherpAI2"
|
||||
android:name=".SherpAIApplication">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -5,66 +5,119 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen
|
||||
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.ui.theme.SherpAI2Theme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* Centralized string-based navigation routes.
|
||||
*/
|
||||
object Routes {
|
||||
const val Search = "search"
|
||||
const val ImageDetail = "image_detail"
|
||||
}
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 1. Define the permission needed based on API level
|
||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val mediaPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
setContent {
|
||||
SherpAI2Theme {
|
||||
// 2. State to track if permission is granted
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED)
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(this, mediaPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Launcher to ask for permission
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
hasPermission = isGranted
|
||||
}
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
// 4. Trigger request on start
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasPermission) launcher.launch(permission)
|
||||
if (!hasPermission) permissionLauncher.launch(mediaPermission)
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
MainScreen() // Your existing screen that holds MainContentArea
|
||||
AppNavigation()
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Please grant storage permission to view photos.")
|
||||
}
|
||||
PermissionDeniedScreen()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.placeholder.sherpai2
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class SherpAIApplication : Application()
|
||||
@@ -11,8 +11,6 @@ interface ImageAggregateDao {
|
||||
|
||||
/**
|
||||
* Observe a fully-hydrated image object.
|
||||
*
|
||||
* This is what your detail screen should use.
|
||||
*/
|
||||
@Transaction
|
||||
@Query("""
|
||||
@@ -22,4 +20,29 @@ interface ImageAggregateDao {
|
||||
fun observeImageWithEverything(
|
||||
imageId: String
|
||||
): Flow<ImageWithEverything>
|
||||
|
||||
/**
|
||||
* Observe all images.
|
||||
*/
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT * FROM images
|
||||
ORDER BY capturedAt DESC
|
||||
""")
|
||||
fun observeAllImagesWithEverything(): Flow<List<ImageWithEverything>>
|
||||
|
||||
/**
|
||||
* Observe images filtered by tag value.
|
||||
*
|
||||
* Joins images -> image_tags -> tags
|
||||
*/
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT images.* FROM images
|
||||
INNER JOIN image_tags ON images.imageId = image_tags.imageId
|
||||
INNER JOIN tags ON tags.tagId = image_tags.tagId
|
||||
WHERE tags.value = :tag
|
||||
ORDER BY images.capturedAt DESC
|
||||
""")
|
||||
fun observeImagesWithTag(tag: String): Flow<List<ImageWithEverything>>
|
||||
}
|
||||
|
||||
@@ -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.ImageTagEntity
|
||||
import com.placeholder.sherpai2.data.local.entity.TagEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
@@ -38,4 +40,14 @@ interface ImageTagDao {
|
||||
tagId: String,
|
||||
minConfidence: Float = 0.5f
|
||||
): List<String>
|
||||
|
||||
@Transaction
|
||||
@Query("""
|
||||
SELECT t.*
|
||||
FROM tags t
|
||||
INNER JOIN image_tags it ON t.tagId = it.tagId
|
||||
WHERE it.imageId = :imageId AND it.visibility = 'PUBLIC'
|
||||
""")
|
||||
fun getTagsForImage(imageId: String): Flow<List<TagEntity>>
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.placeholder.sherpai2.di
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.placeholder.sherpai2.data.local.AppDatabase
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageEventDao
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
|
||||
import com.placeholder.sherpai2.data.local.dao.TagDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -25,4 +29,33 @@ object DatabaseModule {
|
||||
"sherpai.db"
|
||||
).build()
|
||||
}
|
||||
|
||||
// --- Add these DAO providers ---
|
||||
|
||||
@Provides
|
||||
fun provideTagDao(database: AppDatabase): TagDao {
|
||||
return database.tagDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideImageTagDao(database: AppDatabase): ImageTagDao {
|
||||
return database.imageTagDao()
|
||||
}
|
||||
|
||||
// Add providers for your other DAOs now to avoid future errors
|
||||
@Provides
|
||||
fun provideImageDao(database: AppDatabase) = database.imageDao()
|
||||
|
||||
@Provides
|
||||
fun providePersonDao(database: AppDatabase) = database.personDao()
|
||||
|
||||
@Provides
|
||||
fun provideEventDao(database: AppDatabase) = database.eventDao()
|
||||
|
||||
@Provides
|
||||
fun provideImageEventDao(database: AppDatabase): ImageEventDao = database.imageEventDao()
|
||||
|
||||
@Provides
|
||||
fun provideImageAggregateDao(database: AppDatabase): ImageAggregateDao = database.imageAggregateDao()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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.TaggingRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindImageRepository(
|
||||
impl: ImageRepositoryImpl
|
||||
): ImageRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTaggingRepository(
|
||||
impl: TaggingRepositoryImpl
|
||||
): TaggingRepository
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.placeholder.sherpai2.domain.repository
|
||||
|
||||
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Canonical access point for images.
|
||||
*
|
||||
* ViewModels must NEVER talk directly to DAOs.
|
||||
*/
|
||||
interface ImageRepository {
|
||||
|
||||
/**
|
||||
* Observe a fully-hydrated image graph.
|
||||
*
|
||||
* Used by detail screens.
|
||||
*/
|
||||
fun observeImage(imageId: String): Flow<ImageWithEverything>
|
||||
|
||||
/**
|
||||
* Ingest images discovered on device.
|
||||
*
|
||||
* This function:
|
||||
* - deduplicates
|
||||
* - assigns events automatically
|
||||
*/
|
||||
suspend fun ingestImages()
|
||||
|
||||
fun getAllImages(): Flow<List<ImageWithEverything>>
|
||||
fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.placeholder.sherpai2.data.repository
|
||||
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ImageRepositoryImpl @Inject constructor(
|
||||
private val imageDao: ImageDao,
|
||||
private val eventDao: EventDao,
|
||||
private val imageEventDao: ImageEventDao,
|
||||
private val aggregateDao: ImageAggregateDao
|
||||
) : ImageRepository {
|
||||
|
||||
override fun observeImage(imageId: String): Flow<com.placeholder.sherpai2.data.local.model.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.
|
||||
*/
|
||||
override suspend fun ingestImages() {
|
||||
// Step 1: fetch all images
|
||||
val images = imageDao.getImagesInRange(
|
||||
start = 0L,
|
||||
end = Long.MAX_VALUE
|
||||
)
|
||||
|
||||
// Step 2: auto-assign events by timestamp
|
||||
images.forEach { image ->
|
||||
val events = eventDao.findEventsForTimestamp(image.capturedAt)
|
||||
|
||||
events.forEach { event ->
|
||||
imageEventDao.upsert(
|
||||
ImageEventEntity(
|
||||
imageId = image.imageId,
|
||||
eventId = event.eventId,
|
||||
source = "AUTO",
|
||||
override = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAllImages(): Flow<List<com.placeholder.sherpai2.data.local.model.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
|
||||
return aggregateDao.observeImagesWithTag(tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.placeholder.sherpai2.domain.repository
|
||||
|
||||
import com.placeholder.sherpai2.data.local.entity.TagEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Handles all tagging operations.
|
||||
*
|
||||
* This repository is the ONLY place where:
|
||||
* - tags are attached
|
||||
* - visibility rules are applied
|
||||
*/
|
||||
interface TaggingRepository {
|
||||
|
||||
suspend fun addTagToImage(
|
||||
imageId: String,
|
||||
tagValue: String,
|
||||
source: String,
|
||||
confidence: Float
|
||||
)
|
||||
|
||||
suspend fun hideTagForImage(
|
||||
imageId: String,
|
||||
tagValue: String
|
||||
)
|
||||
|
||||
fun getTagsForImage(imageId: String): Flow<List<TagEntity>>
|
||||
|
||||
suspend fun removeTagFromImage(imageId: String, tagId: String)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.placeholder.sherpai2.data.repository
|
||||
|
||||
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
|
||||
import com.placeholder.sherpai2.data.local.dao.TagDao
|
||||
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
|
||||
import com.placeholder.sherpai2.data.local.entity.TagEntity
|
||||
import com.placeholder.sherpai2.domain.repository.TaggingRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Critical design decisions here
|
||||
*
|
||||
* Tag normalization happens once
|
||||
*
|
||||
* Visibility rules live here
|
||||
*
|
||||
* ML and manual tagging share the same path
|
||||
*/
|
||||
|
||||
@Singleton
|
||||
class TaggingRepositoryImpl @Inject constructor(
|
||||
private val tagDao: TagDao,
|
||||
private val imageTagDao: ImageTagDao
|
||||
) : TaggingRepository {
|
||||
|
||||
override suspend fun addTagToImage(
|
||||
imageId: String,
|
||||
tagValue: String,
|
||||
source: String,
|
||||
confidence: Float
|
||||
) {
|
||||
// Step 1: normalize tag
|
||||
val normalized = tagValue.trim().lowercase()
|
||||
|
||||
// Step 2: ensure tag exists
|
||||
val tag = tagDao.getByValue(normalized)
|
||||
?: TagEntity(
|
||||
tagId = "tag_$normalized",
|
||||
type = "GENERIC",
|
||||
value = normalized,
|
||||
createdAt = System.currentTimeMillis()
|
||||
).also { tagDao.insert(it) }
|
||||
|
||||
// Step 3: attach tag to image
|
||||
imageTagDao.upsert(
|
||||
ImageTagEntity(
|
||||
imageId = imageId,
|
||||
tagId = tag.tagId,
|
||||
source = source,
|
||||
confidence = confidence,
|
||||
visibility = "PUBLIC",
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun hideTagForImage(
|
||||
imageId: String,
|
||||
tagValue: String
|
||||
) {
|
||||
val tag = tagDao.getByValue(tagValue) ?: return
|
||||
|
||||
imageTagDao.upsert(
|
||||
ImageTagEntity(
|
||||
imageId = imageId,
|
||||
tagId = tag.tagId,
|
||||
source = "MANUAL",
|
||||
confidence = 1.0f,
|
||||
visibility = "HIDDEN",
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getTagsForImage(imageId: String): Flow<List<TagEntity>> {
|
||||
// Join imageTagDao -> tagDao to get all PUBLIC tags for this image
|
||||
return imageTagDao.getTagsForImage(imageId)
|
||||
}
|
||||
|
||||
override suspend fun removeTagFromImage(imageId: String, tagId: String) {
|
||||
// Mark the tag as hidden instead of deleting, keeping the visibility logic
|
||||
imageTagDao.upsert(
|
||||
ImageTagEntity(
|
||||
imageId = imageId,
|
||||
tagId = tagId,
|
||||
source = "MANUAL",
|
||||
confidence = 1.0f,
|
||||
visibility = "HIDDEN",
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.placeholder.sherpai2.ui.imagedetail
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.placeholder.sherpai2.ui.imagedetail.viewmodel.ImageDetailViewModel
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* ImageDetailScreen
|
||||
*
|
||||
* Purpose:
|
||||
* - Add tags
|
||||
* - Remove tags
|
||||
* - Validate write propagation
|
||||
*/
|
||||
@Composable
|
||||
fun ImageDetailScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
imageUri: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val viewModel: ImageDetailViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
||||
|
||||
LaunchedEffect(imageUri) {
|
||||
viewModel.loadImage(imageUri)
|
||||
}
|
||||
|
||||
val tags by viewModel.tags.collectAsStateWithLifecycle()
|
||||
|
||||
var newTag by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = imageUri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = newTag,
|
||||
onValueChange = { newTag = it },
|
||||
label = { Text("Add tag") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.addTag(newTag)
|
||||
newTag = ""
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Text("Add Tag")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
tags.forEach { tag ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(tag.value)
|
||||
TextButton(onClick = { viewModel.removeTag(tag) }) {
|
||||
Text("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.placeholder.sherpai2.ui.imagedetail.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.placeholder.sherpai2.data.local.entity.TagEntity
|
||||
import com.placeholder.sherpai2.domain.repository.TaggingRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ImageDetailViewModel
|
||||
*
|
||||
* Owns:
|
||||
* - Image context
|
||||
* - Tag write operations
|
||||
*/
|
||||
@HiltViewModel
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ImageDetailViewModel @Inject constructor(
|
||||
private val tagRepository: TaggingRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val imageUri = MutableStateFlow<String?>(null)
|
||||
|
||||
val tags: StateFlow<List<TagEntity>> =
|
||||
imageUri
|
||||
.filterNotNull()
|
||||
.flatMapLatest { uri ->
|
||||
tagRepository.getTagsForImage(uri)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
fun loadImage(uri: String) {
|
||||
imageUri.value = uri
|
||||
}
|
||||
|
||||
fun addTag(value: String) {
|
||||
val uri = imageUri.value ?: return
|
||||
viewModelScope.launch {
|
||||
tagRepository.addTagToImage(uri, value, source = "MANUAL", confidence = 1.0f)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTag(tag: TagEntity) {
|
||||
val uri = imageUri.value ?: return
|
||||
viewModelScope.launch {
|
||||
tagRepository.removeTagFromImage(uri, tag.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// In navigation/AppDestinations.kt
|
||||
package com.placeholder.sherpai2.ui.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -6,27 +5,69 @@ import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Defines all navigation destinations (screens) for the application.
|
||||
* AppDestinations
|
||||
*
|
||||
* Centralized definition of all top-level screens.
|
||||
* This avoids stringly-typed navigation.
|
||||
*/
|
||||
sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) {
|
||||
// Core Functional Sections
|
||||
sealed class AppDestinations(
|
||||
val route: String,
|
||||
val icon: ImageVector,
|
||||
val label: String
|
||||
) {
|
||||
|
||||
object Tour : AppDestinations(
|
||||
route = "Tour",
|
||||
route = "tour",
|
||||
icon = Icons.Default.PhotoLibrary,
|
||||
label = "Tour"
|
||||
)
|
||||
object Search : AppDestinations("search", Icons.Default.Search, "Search")
|
||||
object Models : AppDestinations("models", Icons.Default.Layers, "Models")
|
||||
object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv")
|
||||
object Train : AppDestinations("train", Icons.Default.TrackChanges, "Train")
|
||||
object Tags : AppDestinations("tags", Icons.Default.LocalOffer, "Tags")
|
||||
|
||||
// Utility/Secondary Sections
|
||||
object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload")
|
||||
object Settings : AppDestinations("settings", Icons.Default.Settings, "Settings")
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
// Lists used by the AppDrawerContent to render the menu sections easily
|
||||
/**
|
||||
* Drawer grouping
|
||||
*/
|
||||
val mainDrawerItems = listOf(
|
||||
AppDestinations.Tour,
|
||||
AppDestinations.Search,
|
||||
@@ -39,4 +80,4 @@ val mainDrawerItems = listOf(
|
||||
val utilityDrawerItems = listOf(
|
||||
AppDestinations.Upload,
|
||||
AppDestinations.Settings
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// In presentation/MainScreen.kt
|
||||
package com.placeholder.sherpai2.presentation
|
||||
|
||||
import GalleryViewModel
|
||||
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.lifecycle.viewmodel.compose.viewModel
|
||||
import com.placeholder.sherpai2.ui.navigation.AppDestinations
|
||||
import com.placeholder.sherpai2.ui.presentation.AppDrawerContent
|
||||
import com.placeholder.sherpai2.ui.presentation.MainContentArea
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
// State to track which screen is currently visible
|
||||
// var currentScreen by remember { mutableStateOf(AppDestinations.Search) }
|
||||
var currentScreen: AppDestinations by remember { mutableStateOf(AppDestinations.Search) }
|
||||
val galleryViewModel: GalleryViewModel = viewModel()
|
||||
|
||||
// ModalNavigationDrawer provides the left sidebar UI/UX
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
// The content of the drawer (AppDrawerContent)
|
||||
AppDrawerContent(
|
||||
currentScreen = currentScreen,
|
||||
onDestinationClicked = { destination ->
|
||||
currentScreen = destination
|
||||
scope.launch { drawerState.close() } // Close drawer after selection
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
// The main content area
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentScreen.label) },
|
||||
// Button to open the drawer
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = "Open Drawer")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
// Displays the content for the currently selected screen
|
||||
MainContentArea(
|
||||
currentScreen = currentScreen,
|
||||
galleryViewModel = galleryViewModel,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// In presentation/AppDrawerContent.kt
|
||||
package com.placeholder.sherpai2.ui.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.placeholder.sherpai2.ui.navigation.AppDestinations
|
||||
import com.placeholder.sherpai2.ui.navigation.mainDrawerItems
|
||||
import com.placeholder.sherpai2.ui.navigation.utilityDrawerItems
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppDrawerContent(
|
||||
currentScreen: AppDestinations,
|
||||
onDestinationClicked: (AppDestinations) -> Unit
|
||||
) {
|
||||
// Defines the width and content of the sliding drawer panel
|
||||
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
|
||||
|
||||
// Header/Logo Area
|
||||
Text(
|
||||
"SherpAI Control Panel",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
Divider(Modifier.fillMaxWidth())
|
||||
|
||||
// 1. Main Navigation Items
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
mainDrawerItems.forEach { destination ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(destination.label) },
|
||||
icon = { Icon(destination.icon, contentDescription = destination.label) },
|
||||
selected = destination == currentScreen,
|
||||
onClick = { onDestinationClicked(destination) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Separator
|
||||
Divider(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||
|
||||
// 2. Utility Items
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
utilityDrawerItems.forEach { destination ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(destination.label) },
|
||||
icon = { Icon(destination.icon, contentDescription = destination.label) },
|
||||
selected = destination == currentScreen,
|
||||
onClick = { onDestinationClicked(destination) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// In presentation/MainContentArea.kt
|
||||
package com.placeholder.sherpai2.ui.presentation
|
||||
|
||||
import GalleryScreen
|
||||
import GalleryViewModel
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.placeholder.sherpai2.ui.navigation.AppDestinations
|
||||
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@Composable
|
||||
fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier, galleryViewModel: GalleryViewModel = viewModel() ) {
|
||||
val uiState by galleryViewModel.uiState.collectAsState()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Red),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Swaps the UI content based on the selected screen from the drawer
|
||||
when (currentScreen) {
|
||||
AppDestinations.Tour -> GalleryScreen(state = uiState, modifier = Modifier)
|
||||
AppDestinations.Search -> SimplePlaceholder("Find Any Photos.")
|
||||
AppDestinations.Models -> SimplePlaceholder("Models Screen: Manage your LoRA/embeddings.")
|
||||
AppDestinations.Inventory -> SimplePlaceholder("Inventory Screen: View all collected data.")
|
||||
AppDestinations.Train -> SimplePlaceholder("Train Screen: Start the LoRA adaptation process.")
|
||||
AppDestinations.Tags -> SimplePlaceholder("Tags Screen: Create and edit custom tags.")
|
||||
AppDestinations.Upload -> SimplePlaceholder("Upload Screen: Import new photos/data.")
|
||||
AppDestinations.Settings -> SimplePlaceholder("Settings Screen: Configure app behavior.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimplePlaceholder(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(16.dp).background(color = Color.Magenta)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.placeholder.sherpai2.ui.presentation
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
import com.placeholder.sherpai2.data.photos.Photo
|
||||
|
||||
@Composable
|
||||
fun PhotoListScreen(
|
||||
photos: List<Photo>
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
) {
|
||||
items(photos, key = { it.id }) { photo ->
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(photo.uri),
|
||||
contentDescription = photo.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.placeholder.sherpai2.ui.search
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
|
||||
import com.placeholder.sherpai2.ui.search.SearchViewModel
|
||||
|
||||
/**
|
||||
* SearchScreen
|
||||
*
|
||||
* Purpose:
|
||||
* - Validate tag-based queries
|
||||
* - Preview matching images
|
||||
*
|
||||
* This is NOT final UX.
|
||||
* It is a diagnostic surface.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
searchViewModel: SearchViewModel,
|
||||
onImageClick: (String) -> Unit
|
||||
) {
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
/**
|
||||
* Reactive result set.
|
||||
* Updates whenever:
|
||||
* - query changes
|
||||
* - database changes
|
||||
*/
|
||||
val images by searchViewModel
|
||||
.searchImagesByTag(query)
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text("Search by tag") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(120.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(images) { imageWithEverything ->
|
||||
ImageGridItem(image = imageWithEverything.image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.placeholder.sherpai2.ui.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* SearchViewModel
|
||||
*
|
||||
* Stateless except for query-driven flows.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val imageRepository: ImageRepository
|
||||
) : ViewModel() {
|
||||
|
||||
fun searchImagesByTag(tag: String) =
|
||||
if (tag.isBlank()) {
|
||||
imageRepository.getAllImages()
|
||||
} else {
|
||||
imageRepository.findImagesByTag(tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.placeholder.sherpai2.ui.search.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
||||
|
||||
/**
|
||||
* ImageGridItem
|
||||
*
|
||||
* Minimal thumbnail preview.
|
||||
* No click handling yet.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageGridItem(
|
||||
image: ImageEntity
|
||||
) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(image.imageUri),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.placeholder.sherpai2.data.photos.Photo
|
||||
import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
|
||||
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun GalleryScreen(
|
||||
state: GalleryUiState,
|
||||
modifier: Modifier = Modifier // Add default modifier
|
||||
) {
|
||||
// Note: If this is inside MainContentArea, you might not need a second Scaffold.
|
||||
// Let's use a Column or Box to ensure it fills the space correctly.
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Text(
|
||||
text = "Photo Gallery",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when (state) {
|
||||
is GalleryUiState.Loading -> CircularProgressIndicator()
|
||||
is GalleryUiState.Error -> Text(text = state.message)
|
||||
is GalleryUiState.Success -> {
|
||||
if (state.photos.isEmpty()) {
|
||||
Text("No photos found. Try adding some to the emulator!")
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(state.photos) { photo ->
|
||||
PhotoItem(photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun PhotoItem(photo: Photo) {
|
||||
AsyncImage(
|
||||
model = photo.uri,
|
||||
contentDescription = photo.title,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f) // Makes it a square
|
||||
.fillMaxWidth(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.placeholder.sherpai2.ui.tourscreen
|
||||
|
||||
import com.placeholder.sherpai2.data.photos.Photo
|
||||
|
||||
sealed class GalleryUiState {
|
||||
object Loading : GalleryUiState()
|
||||
data class Success(val photos: List<Photo>) : GalleryUiState()
|
||||
data class Error(val message: String) : GalleryUiState()
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.placeholder.sherpai2.data.photos.Photo
|
||||
import com.placeholder.sherpai2.data.repo.PhotoRepository
|
||||
import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GalleryViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Initialize repository with the application context
|
||||
private val repository = PhotoRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow<GalleryUiState>(GalleryUiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPhotos()
|
||||
}
|
||||
|
||||
fun loadPhotos() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.scanExternalStorage()
|
||||
|
||||
result.onSuccess { photos ->
|
||||
_uiState.value = GalleryUiState.Success(photos)
|
||||
}.onFailure { error ->
|
||||
_uiState.value = GalleryUiState.Error(error.message ?: "Unknown Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.placeholder.sherpai2.ui.tourscreen.components
|
||||
|
||||
class AlbumBox {
|
||||
}
|
||||
@@ -3,5 +3,9 @@ plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
|
||||
//Adding these two fixes the conflicts between hilt / room / ksp and javbapoet (cannonicalName())
|
||||
//https://github.com/google/dagger/issues/4048#issuecomment-1864237679
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.hilt.android) apply false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
# Tooling
|
||||
agp = "8.7.3"
|
||||
agp = "8.13.2"
|
||||
kotlin = "2.0.21"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
|
||||
@@ -8,32 +8,28 @@ ksp = "2.0.21-1.0.28"
|
||||
coreKtx = "1.15.0"
|
||||
lifecycle = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.12.01"
|
||||
composeBom = "2025.12.01"
|
||||
navigationCompose = "2.8.5"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
|
||||
# DI
|
||||
hilt = "2.52"
|
||||
# DI & Database
|
||||
hilt = "2.57.2"
|
||||
room = "2.8.4"
|
||||
|
||||
# Images
|
||||
coil = "2.7.0"
|
||||
|
||||
#backend2
|
||||
#Room
|
||||
room = "2.6.1"
|
||||
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
|
||||
# Compose BOM & UI
|
||||
# Compose
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } # ADDED THIS
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
@@ -44,15 +40,13 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
|
||||
# Misc
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
#backend2
|
||||
#Room
|
||||
# Room
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# Misc
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user