2 Commits

Author SHA1 Message Date
genki
0f7f4a4201 Cleaner - Needs UI rebuild from Master TBD 2025-12-25 22:18:58 -05:00
genki
0d34a2510b Mess - Crash on boot - Backend ?? 2025-12-25 00:40:57 -05:00
28 changed files with 760 additions and 411 deletions

View File

@@ -12,7 +12,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.placeholder.sherpai2" applicationId = "com.placeholder.sherpai2"
minSdk = 24 minSdk = 25
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -42,35 +42,34 @@ android {
} }
dependencies { dependencies {
// AndroidX & Lifecycle // Core & Lifecycle
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
// Compose (Using BOM) // Compose
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons) 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) debugImplementation(libs.androidx.compose.ui.tooling)
//backend2 // Hilt DI
//room addon 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.runtime)
implementation(libs.room.ktx) implementation(libs.room.ktx)
ksp(libs.room.compiler) ksp(libs.room.compiler)
// Coil Images
implementation(libs.coil.compose)
} }

View File

@@ -10,7 +10,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SherpAI2"> android:theme="@style/Theme.SherpAI2"
android:name=".SherpAIApplication">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -5,66 +5,119 @@ 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.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.compose.rememberLauncherForActivityResult
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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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 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 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() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 1. Define the permission needed based on API level val mediaPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES Manifest.permission.READ_MEDIA_IMAGES
} else { } else {
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
} }
setContent { setContent {
SherpAI2Theme { SherpAI2Theme {
// 2. State to track if permission is granted
var hasPermission by remember { 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 permissionLauncher = rememberLauncherForActivityResult(
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
) { isGranted -> ) { granted -> hasPermission = granted }
hasPermission = isGranted
}
// 4. Trigger request on start
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!hasPermission) launcher.launch(permission) if (!hasPermission) permissionLauncher.launch(mediaPermission)
} }
if (hasPermission) { if (hasPermission) {
MainScreen() // Your existing screen that holds MainContentArea AppNavigation()
} else { } else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { PermissionDeniedScreen()
Text("Please grant storage permission to view photos.")
}
} }
} }
}
} }
}
} }
@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.")
}
}

View File

@@ -0,0 +1,7 @@
package com.placeholder.sherpai2
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class SherpAIApplication : Application()

View File

@@ -11,8 +11,6 @@ interface ImageAggregateDao {
/** /**
* Observe a fully-hydrated image object. * Observe a fully-hydrated image object.
*
* This is what your detail screen should use.
*/ */
@Transaction @Transaction
@Query(""" @Query("""
@@ -22,4 +20,29 @@ interface ImageAggregateDao {
fun observeImageWithEverything( fun observeImageWithEverything(
imageId: String imageId: String
): Flow<ImageWithEverything> ): 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>>
} }

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.ImageTagEntity import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@@ -38,4 +40,14 @@ interface ImageTagDao {
tagId: String, tagId: String,
minConfidence: Float = 0.5f minConfidence: Float = 0.5f
): List<String> ): 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>>
} }

View File

@@ -3,6 +3,10 @@ package com.placeholder.sherpai2.di
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.placeholder.sherpai2.data.local.AppDatabase 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.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -25,4 +29,33 @@ object DatabaseModule {
"sherpai.db" "sherpai.db"
).build() ).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()
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
// In navigation/AppDestinations.kt
package com.placeholder.sherpai2.ui.navigation package com.placeholder.sherpai2.ui.navigation
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -6,27 +5,69 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector 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) { sealed class AppDestinations(
// Core Functional Sections val route: String,
val icon: ImageVector,
val label: String
) {
object Tour : AppDestinations( object Tour : AppDestinations(
route = "Tour", route = "tour",
icon = Icons.Default.PhotoLibrary, icon = Icons.Default.PhotoLibrary,
label = "Tour" 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 Search : AppDestinations(
object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload") route = "search",
object Settings : AppDestinations("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"
)
} }
// Lists used by the AppDrawerContent to render the menu sections easily /**
* Drawer grouping
*/
val mainDrawerItems = listOf( val mainDrawerItems = listOf(
AppDestinations.Tour, AppDestinations.Tour,
AppDestinations.Search, AppDestinations.Search,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
package com.placeholder.sherpai2.ui.tourscreen.components
class AlbumBox {
}

View File

@@ -3,5 +3,9 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) 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.ksp) apply false
alias(libs.plugins.hilt.android) apply false
} }

View File

@@ -1,6 +1,6 @@
[versions] [versions]
# Tooling # Tooling
agp = "8.7.3" agp = "8.13.2"
kotlin = "2.0.21" kotlin = "2.0.21"
ksp = "2.0.21-1.0.28" ksp = "2.0.21-1.0.28"
@@ -8,32 +8,28 @@ ksp = "2.0.21-1.0.28"
coreKtx = "1.15.0" coreKtx = "1.15.0"
lifecycle = "2.8.7" lifecycle = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.9.3"
composeBom = "2024.12.01" composeBom = "2025.12.01"
navigationCompose = "2.8.5" navigationCompose = "2.8.5"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.3.0"
# DI # DI & Database
hilt = "2.52" hilt = "2.57.2"
room = "2.8.4"
# Images # Images
coil = "2.7.0" coil = "2.7.0"
#backend2
#Room
room = "2.6.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-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" } 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-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 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-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } 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-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Misc # Room
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
#backend2
#Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }