Adding HILT DI and First Pass at backend Kotlin

This commit is contained in:
genki
2025-12-24 10:18:46 -05:00
parent c10cbf373f
commit 49e6523f75
38 changed files with 1286 additions and 257 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
/.idea/

View File

@@ -1,77 +1,75 @@
// build.gradle.kts (Module: :app)
plugins { plugins {
// 1. Core Android and Kotlin plugins (MUST be first) alias(libs.plugins.android.application)
id("com.android.application") alias(libs.plugins.kotlin.android)
kotlin("android") alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
id("org.jetbrains.kotlin.plugin.compose") // Note: No version is specified here alias(libs.plugins.hilt.android)
} }
android { android {
// 2. Android Configuration
namespace = "com.placeholder.sherpai2" namespace = "com.placeholder.sherpai2"
compileSdk = 34 compileSdk = 36 // SDK 35 is the stable standard for 2025; 36 is preview
defaultConfig { defaultConfig {
applicationId = "com.placeholder.sherpai2" applicationId = "com.placeholder.sherpai2"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
// 3. Kotlin & Java Settings
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "1.8" kotlinOptions {
jvmTarget = "17"
} }
// 4. Jetpack Compose Configuration (Crucial!)
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions { androidResources {
kotlinCompilerExtensionVersion = "1.5.8" // Must match your Kotlin version noCompress += "tflite"
} }
} }
dependencies { // FIX for hiltAggregateDepsDebug: Correctly configure the Hilt extension
// --- CORE ANDROID & LIFECYCLE --- hilt {
implementation("androidx.core:core-ktx:1.12.0") enableAggregatingTask = false
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") }
implementation("androidx.activity:activity-compose:1.8.2") // Fixes 'activity' ref error
dependencies {
// --- JETPACK COMPOSE UI (Material 3) --- // Compose
implementation("androidx.compose.ui:ui") implementation(platform(libs.androidx.compose.bom))
implementation("androidx.compose.ui:ui-graphics") implementation(libs.androidx.core.ktx)
implementation("androidx.compose.ui:ui-tooling-preview") implementation(libs.androidx.lifecycle.runtime)
implementation("androidx.compose.material3:material3") // Fixes 'material3' ref error implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui)
// --- COMPOSE ICONS (Fixes 'material' and 'Icons' ref errors) --- implementation(libs.compose.material3)
// Uses direct string to avoid Version Catalog conflicts implementation(libs.compose.icons)
implementation("androidx.compose.material:material-icons-extended:1.6.0") implementation(libs.compose.navigation)
debugImplementation(libs.compose.ui.tooling)
// --- STATE MANAGEMENT / COROUTINES ---
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") // Camera & ML
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation(libs.camera.core)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
// --- TESTING --- implementation(libs.mlkit.face)
testImplementation("junit:junit:4.13.2") implementation(libs.tflite)
androidTestImplementation("androidx.test.ext:junit:1.1.5") implementation(libs.tflite.support)
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4") // Room (KSP)
debugImplementation("androidx.compose.ui:ui-tooling") implementation(libs.room.runtime)
debugImplementation("androidx.compose.ui:ui-test-manifest") ksp(libs.room.compiler)
implementation("androidx.compose.foundation:foundation:1.6.0") // Use your current Compose version // Images
implementation("androidx.compose.material3:material3:1.2.1") // <-- Fix/Reconfirm Material 3 implementation(libs.coil.compose)
implementation("io.coil-kt:coil-compose:2.6.0") // Hilt (KSP) - Fixed by removing kapt and using ksp
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
} }

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

@@ -23,7 +23,9 @@ 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 com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

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

@@ -0,0 +1,58 @@
package com.placeholder.sherpai2.data.di
import android.content.Context
import com.placeholder.sherpai2.data.local.FaceDao
import com.placeholder.sherpai2.data.local.FaceDatabase
import com.placeholder.sherpai2.data.repo.FaceRepository
import com.placeholder.sherpai2.domain.faces.analyzer.FaceAnalyzer
import com.placeholder.sherpai2.domain.faces.ml.FaceNetInterpreter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
// ---------- Database ----------
@Provides
@Singleton
fun provideFaceDatabase(
@ApplicationContext context: Context
): FaceDatabase =
FaceDatabase.getInstance(context)
@Provides
fun provideFaceDao(
db: FaceDatabase
): FaceDao = db.faceDao()
// ---------- Repository ----------
@Provides
@Singleton
fun provideFaceRepository(
dao: FaceDao
): FaceRepository =
FaceRepository(dao)
// ---------- ML ----------
@Provides
@Singleton
fun provideFaceNetInterpreter(
@ApplicationContext context: Context
): FaceNetInterpreter =
FaceNetInterpreter(context)
@Provides
@Singleton
fun provideFaceAnalyzer(
@ApplicationContext context: Context
): FaceAnalyzer =
FaceAnalyzer(context)
}

View File

@@ -0,0 +1,31 @@
package com.placeholder.sherpai2.data.local
import androidx.room.TypeConverter
import java.nio.ByteBuffer
/**
* Converts FloatArray to ByteArray and back for Room persistence.
*/
object Converters {
@TypeConverter
@JvmStatic
fun fromFloatArray(value: FloatArray): ByteArray {
val buffer = ByteBuffer.allocate(value.size * 4)
for (f in value) {
buffer.putFloat(f)
}
return buffer.array()
}
@TypeConverter
@JvmStatic
fun toFloatArray(bytes: ByteArray): FloatArray {
val buffer = ByteBuffer.wrap(bytes)
val floats = FloatArray(bytes.size / 4)
for (i in floats.indices) {
floats[i] = buffer.getFloat()
}
return floats
}
}

View File

@@ -0,0 +1,26 @@
package com.placeholder.sherpai2.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
/**
* DAO for face embeddings.
*/
@Dao
interface FaceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(face: FaceEntity): Long
@Query("SELECT * FROM faces")
suspend fun getAllFaces(): List<FaceEntity>
@Query("SELECT * FROM faces WHERE label = :label LIMIT 1")
suspend fun getFaceByLabel(label: String): FaceEntity?
@Query("DELETE FROM faces")
suspend fun clearAll()
}

View File

@@ -0,0 +1,38 @@
package com.placeholder.sherpai2.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
/**
* Room database for storing face embeddings.
*/
@Database(
entities = [FaceEntity::class],
version = 1,
exportSchema = false
)
abstract class FaceDatabase : RoomDatabase() {
abstract fun faceDao(): FaceDao
companion object {
@Volatile
private var INSTANCE: FaceDatabase? = null
fun getInstance(context: Context): FaceDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
FaceDatabase::class.java,
"face_database"
)
.fallbackToDestructiveMigration() // Safe for dev; can be removed in prod
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,23 @@
package com.placeholder.sherpai2.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
/**
* Room entity representing a single face embedding.
*
* @param id Auto-generated primary key
* @param label Name or identifier for the face
* @param embedding FloatArray of length 128/512 representing the face
*/
@Entity(tableName = "faces")
@TypeConverters(Converters::class)
data class FaceEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val label: String,
val embedding: FloatArray
)

View File

@@ -0,0 +1,90 @@
package com.placeholder.sherpai2.data.repo
import com.placeholder.sherpai2.data.local.FaceDao
import com.placeholder.sherpai2.data.local.FaceEntity
import com.placeholder.sherpai2.domain.util.EmbeddingMath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Repository for managing face embeddings.
*
* Handles:
* - Saving new embeddings
* - Querying embeddings
* - Matching a new embedding against stored embeddings
*/
class FaceRepository(
private val faceDao: FaceDao
) {
/**
* Save a new face embedding with the given label.
*/
suspend fun saveFace(label: String, embedding: FloatArray): Long =
withContext(Dispatchers.IO) {
EmbeddingMath.l2Normalize(embedding)
val entity = FaceEntity(label = label, embedding = embedding)
faceDao.insert(entity)
}
/**
* Retrieve all stored embeddings.
*/
suspend fun getAllFaces(): List<FaceEntity> =
withContext(Dispatchers.IO) {
faceDao.getAllFaces()
}
/**
* Find the most similar stored face to the given embedding.
*
* Returns the matched FaceEntity and similarity metrics,
* or null if no match exceeds the provided thresholds.
*/
suspend fun findClosestMatch(
embedding: FloatArray,
cosineThreshold: Float = 0.80f,
euclideanThreshold: Float = 1.10f
): FaceMatchResult? = withContext(Dispatchers.IO) {
EmbeddingMath.l2Normalize(embedding)
val faces = faceDao.getAllFaces()
if (faces.isEmpty()) return@withContext null
var bestMatch: FaceEntity? = null
var bestCosine = -1f
var bestEuclidean = Float.MAX_VALUE
for (face in faces) {
val storedEmbedding = face.embedding.copyOf()
EmbeddingMath.l2Normalize(storedEmbedding)
val cosine = EmbeddingMath.cosineSimilarity(embedding, storedEmbedding)
val euclidean = EmbeddingMath.euclideanDistance(embedding, storedEmbedding)
if (cosine > bestCosine && euclidean < euclideanThreshold && cosine >= cosineThreshold) {
bestCosine = cosine
bestEuclidean = euclidean
bestMatch = face
}
}
bestMatch?.let {
FaceMatchResult(
face = it,
cosineSimilarity = bestCosine,
euclideanDistance = bestEuclidean
)
}
}
}
/**
* Result of a face comparison.
*/
data class FaceMatchResult(
val face: FaceEntity,
val cosineSimilarity: Float,
val euclideanDistance: Float
)

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import com.placeholder.sherpai2.data.photos.Photo import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.domain.PhotoDuplicateScanner
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -45,6 +46,13 @@ class PhotoRepository(private val context: Context) {
dateModified = (file.lastModified() / 1000).toInt() dateModified = (file.lastModified() / 1000).toInt()
) )
} }
fun findDuplicates(photos: Photo) {
TODO("Not yet implemented")
}
fun fetchPhotos() {
TODO("Not yet implemented")
}
private fun isImageFile(ext: String) = listOf("jpg", "jpeg", "png").contains(ext.lowercase()) private fun isImageFile(ext: String) = listOf("jpg", "jpeg", "png").contains(ext.lowercase())
} }

View File

@@ -1,7 +1,19 @@
package com.placeholder.sherpai2.domain package com.placeholder.sherpai2.domain
import android.content.Context import android.content.Context
import com.placeholder.sherpai2.data.photos.Photo
class PhotoDuplicateScanner(private val context: Context) { class PhotoDuplicateScanner(private val context: Context) {
/**
* Finds duplicate photos by grouping them by file size or name.
* In a production app, you might use MD5 hashes or PHash for visual similarity.
*/
fun findDuplicates(allPhotos: List<Photo>): Map<String, List<Photo>> {
// Grouping by size is a fast, common way to find potential duplicates
return allPhotos
.groupBy { it.size } // Groups photos that have the exact same byte size
.filter { it.value.size > 1 } // Only keep groups where more than one photo was found
.mapKeys { "Size: ${it.key} bytes" } // Create a readable label for the group
}
} }

View File

@@ -0,0 +1,2 @@
package com.placeholder.sherpai2.domain.faces

View File

@@ -0,0 +1,113 @@
package com.placeholder.sherpai2.domain.faces.analyzer
import android.content.Context
import android.graphics.Bitmap
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.Face
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetector
import com.google.mlkit.vision.face.FaceDetectorOptions
import com.placeholder.sherpai2.domain.faces.ml.FaceNetInterpreter
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Orchestrates the full face analysis pipeline for still images:
*
* 1. Detect faces using ML Kit
* 2. Select dominant face
* 3. Crop face from bitmap
* 4. Generate FaceNet embedding
*
* This class contains no UI logic and no persistence logic.
*/
class FaceAnalyzer(
context: Context
) {
private val faceDetector: FaceDetector
private val faceNet: FaceNetInterpreter
init {
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setContourMode(FaceDetectorOptions.CONTOUR_MODE_NONE)
.build()
faceDetector = FaceDetection.getClient(options)
faceNet = FaceNetInterpreter(context)
}
/**
* Entry point: analyze a bitmap and return its face embedding.
*
* @throws IllegalStateException if no face is detected
*/
suspend fun analyze(bitmap: Bitmap): FloatArray {
val faces = detectFaces(bitmap)
if (faces.isEmpty()) {
throw IllegalStateException("No face detected in image")
}
val primaryFace = selectDominantFace(faces)
val croppedFace = cropFace(bitmap, primaryFace)
val inputBuffer = faceNet.bitmapToInputBuffer(croppedFace)
return faceNet.runEmbedding(inputBuffer)
}
/**
* Runs ML Kit face detection on a still image.
*/
private suspend fun detectFaces(bitmap: Bitmap): List<Face> =
suspendCancellableCoroutine { cont ->
val image = InputImage.fromBitmap(bitmap, 0)
faceDetector.process(image)
.addOnSuccessListener { faces ->
cont.resume(faces)
}
.addOnFailureListener { e ->
cont.resumeWithException(e)
}
}
/**
* Selects the largest face by bounding box area.
*/
private fun selectDominantFace(faces: List<Face>): Face {
return faces.maxBy { face ->
face.boundingBox.width() * face.boundingBox.height()
}
}
/**
* Crops the detected face region from the original bitmap.
* Bounds are clamped to avoid IllegalArgumentException.
*/
private fun cropFace(bitmap: Bitmap, face: Face): Bitmap {
val box = face.boundingBox
val left = box.left.coerceAtLeast(0)
val top = box.top.coerceAtLeast(0)
val right = box.right.coerceAtMost(bitmap.width)
val bottom = box.bottom.coerceAtMost(bitmap.height)
val width = (right - left).coerceAtLeast(1)
val height = (bottom - top).coerceAtLeast(1)
return Bitmap.createBitmap(bitmap, left, top, width, height)
}
/**
* Explicit cleanup hook.
*/
fun close() {
faceDetector.close()
faceNet.close()
}
}

View File

@@ -0,0 +1,111 @@
package com.placeholder.sherpai2.domain.faces.ml
import android.content.Context
import android.graphics.Bitmap
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.support.common.FileUtil
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Thin wrapper around TensorFlow Lite Interpreter.
* Responsible ONLY for:
* - Loading the model
* - Preparing input buffers
* - Running inference
*/
class FaceNetInterpreter(
context: Context
) {
private val interpreter: Interpreter
init {
val modelBuffer = FileUtil.loadMappedFile(
context,
ModelConstants.MODEL_FILE_NAME
)
val options = Interpreter.Options().apply {
setNumThreads(4)
// GPU delegate intentionally NOT enabled yet
}
interpreter = Interpreter(modelBuffer, options)
}
/**
* Converts a face bitmap into a normalized input buffer.
* Expects a tightly-cropped face.
*/
fun bitmapToInputBuffer(bitmap: Bitmap): ByteBuffer {
val resized = Bitmap.createScaledBitmap(
bitmap,
ModelConstants.INPUT_WIDTH,
ModelConstants.INPUT_HEIGHT,
true
)
val buffer = ByteBuffer.allocateDirect(
1 *
ModelConstants.INPUT_WIDTH *
ModelConstants.INPUT_HEIGHT *
ModelConstants.INPUT_CHANNELS *
4
).apply {
order(ByteOrder.nativeOrder())
}
val pixels = IntArray(
ModelConstants.INPUT_WIDTH * ModelConstants.INPUT_HEIGHT
)
resized.getPixels(
pixels,
0,
ModelConstants.INPUT_WIDTH,
0,
0,
ModelConstants.INPUT_WIDTH,
ModelConstants.INPUT_HEIGHT
)
for (pixel in pixels) {
buffer.putFloat(
((pixel shr 16 and 0xFF) - ModelConstants.IMAGE_MEAN) /
ModelConstants.IMAGE_STD
)
buffer.putFloat(
((pixel shr 8 and 0xFF) - ModelConstants.IMAGE_MEAN) /
ModelConstants.IMAGE_STD
)
buffer.putFloat(
((pixel and 0xFF) - ModelConstants.IMAGE_MEAN) /
ModelConstants.IMAGE_STD
)
}
buffer.rewind()
return buffer
}
/**
* Runs FaceNet inference and returns the embedding vector.
*/
fun runEmbedding(inputBuffer: ByteBuffer): FloatArray {
val output = Array(1) {
FloatArray(ModelConstants.EMBEDDING_SIZE)
}
interpreter.run(inputBuffer, output)
return output[0]
}
/**
* Clean up explicitly when the app shuts down.
*/
fun close() {
interpreter.close()
}
}

View File

@@ -0,0 +1,27 @@
package com.placeholder.sherpai2.domain.faces.ml
/**
* Centralized constants for FaceNet-style models.
* Changing models should only require edits in this file.
*/
object ModelConstants {
// FaceNet standard input size
const val INPUT_WIDTH = 160
const val INPUT_HEIGHT = 160
const val INPUT_CHANNELS = 3
// Output embedding size (FaceNet variants are typically 128 or 512)
const val EMBEDDING_SIZE = 128
// Normalization constants (FaceNet expects [-1, 1])
const val IMAGE_MEAN = 127.5f
const val IMAGE_STD = 128.0f
// Asset path
const val MODEL_FILE_NAME = "facenet_model.tflite"
// Similarity thresholds (tunable later)
const val COSINE_SIMILARITY_THRESHOLD = 0.80f
const val EUCLIDEAN_DISTANCE_THRESHOLD = 1.10f
}

View File

@@ -0,0 +1,59 @@
package com.placeholder.sherpai2.domain.util
import kotlin.math.sqrt
/**
* Utilities for operating on FaceNet-style embeddings.
*
* All functions are pure and testable.
*/
object EmbeddingMath {
/**
* L2-normalizes a float array in place.
* Ensures embedding vectors have unit length for cosine similarity.
*/
fun l2Normalize(embedding: FloatArray) {
var sum = 0.0
for (v in embedding) {
sum += (v * v)
}
val norm = sqrt(sum)
if (norm > 0.0) {
for (i in embedding.indices) {
embedding[i] = (embedding[i] / norm).toFloat()
}
}
}
/**
* Computes cosine similarity between two embeddings.
* Both embeddings should be L2-normalized for correct results.
* Returns value in [-1, 1], higher = more similar.
*/
fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
require(a.size == b.size) { "Embedding size mismatch: ${a.size} != ${b.size}" }
var dot = 0.0f
for (i in a.indices) {
dot += a[i] * b[i]
}
return dot
}
/**
* Computes Euclidean distance between two embeddings.
* Returns a non-negative float; smaller = more similar.
*/
fun euclideanDistance(a: FloatArray, b: FloatArray): Float {
require(a.size == b.size) { "Embedding size mismatch: ${a.size} != ${b.size}" }
var sum = 0.0f
for (i in a.indices) {
val diff = a[i] - b[i]
sum += diff * diff
}
return sqrt(sum)
}
}

View File

@@ -1,42 +1,33 @@
// In navigation/AppDestinations.kt // In navigation/AppDestinations.kt
package com.placeholder.sherpai2.ui.navigation package com.placeholder.sherpai2.ui.navigation
/**
* Defines all navigation destinations (screens) for the application.
*/
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* 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. * Defines all navigation destinations (screens) for the application.
* Changed to 'enum class' to enable built-in iteration (.entries) for NavHost.
*/ */
sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) { enum class AppDestinations(val route: String, val icon: ImageVector, val label: String) {
// Core Functional Sections // Core Functional Sections
object Tour : AppDestinations( Tour("tour", Icons.Default.PhotoLibrary, "Tour"),
route = "Tour", Search("search", Icons.Default.Search, "Search"),
icon = Icons.Default.PhotoLibrary, Models("models", Icons.Default.Layers, "Models"),
label = "Tour" Inventory("inv", Icons.Default.Inventory2, "Inventory"),
) Train("train", Icons.Default.TrackChanges, "Train"),
object Search : AppDestinations("search", Icons.Default.Search, "Search") Tags("tags", Icons.Default.LocalOffer, "Tags"),
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 // Utility/Secondary Sections
object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload") Upload("upload", Icons.Default.CloudUpload, "Upload"),
object Settings : AppDestinations("settings", Icons.Default.Settings, "Settings") Settings("settings", Icons.Default.Settings, "Settings");
companion object {
// High-level grouping for the Drawer UI
val mainDrawerItems = listOf(Tour, Search, Models, Inventory, Train, Tags)
val utilityDrawerItems = listOf(Upload, Settings)
}
} }
// Lists used by the AppDrawerContent to render the menu sections easily
val mainDrawerItems = listOf(
AppDestinations.Tour,
AppDestinations.Search,
AppDestinations.Models,
AppDestinations.Inventory,
AppDestinations.Train,
AppDestinations.Tags
)
val utilityDrawerItems = listOf(
AppDestinations.Upload,
AppDestinations.Settings
)

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

@@ -7,29 +7,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.placeholder.sherpai2.ui.navigation.AppDestinations 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 @Composable
fun AppDrawerContent( fun AppDrawerContent(
currentScreen: AppDestinations, currentScreen: AppDestinations,
onDestinationClicked: (AppDestinations) -> Unit onDestinationClicked: (AppDestinations) -> Unit
) { ) {
// Defines the width and content of the sliding drawer panel
ModalDrawerSheet(modifier = Modifier.width(280.dp)) { ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
// Header/Logo Area // Header Area
Text( Text(
"SherpAI Control Panel", text = "SherpAI Control Panel",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
Divider(Modifier.fillMaxWidth())
// 1. Main Navigation Items HorizontalDivider(modifier = Modifier.fillMaxWidth())
// 1. Main Navigation Items (Referencing the Companion Object)
Column(modifier = Modifier.padding(vertical = 8.dp)) { Column(modifier = Modifier.padding(vertical = 8.dp)) {
mainDrawerItems.forEach { destination -> AppDestinations.mainDrawerItems.forEach { destination ->
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(destination.label) }, label = { Text(destination.label) },
icon = { Icon(destination.icon, contentDescription = destination.label) }, icon = { Icon(destination.icon, contentDescription = destination.label) },
@@ -41,11 +38,15 @@ fun AppDrawerContent(
} }
// Separator // Separator
Divider(Modifier.fillMaxWidth().padding(vertical = 8.dp)) HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
// 2. Utility Items // 2. Utility Items (Referencing the Companion Object)
Column(modifier = Modifier.padding(vertical = 8.dp)) { Column(modifier = Modifier.padding(vertical = 8.dp)) {
utilityDrawerItems.forEach { destination -> AppDestinations.utilityDrawerItems.forEach { destination ->
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(destination.label) }, label = { Text(destination.label) },
icon = { Icon(destination.icon, contentDescription = destination.label) }, icon = { Icon(destination.icon, contentDescription = destination.label) },

View File

@@ -1,12 +1,11 @@
// In presentation/MainContentArea.kt // In presentation/MainContentArea.kt
package com.placeholder.sherpai2.ui.presentation package com.placeholder.sherpai2.ui.presentation
import GalleryScreen import GalleryScreen
import GalleryViewModel import GalleryViewModel
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -14,43 +13,66 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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 import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.placeholder.sherpai2.ui.navigation.AppDestinations
import com.placeholder.sherpai2.ui.screens.managephotos.ManagePhotosScreen
import com.placeholder.sherpai2.ui.screens.managephotos.ManagePhotosViewModel
@Composable @Composable
fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier, galleryViewModel: GalleryViewModel = viewModel() ) { fun MainContentArea(
val uiState by galleryViewModel.uiState.collectAsState() navController: NavHostController, // Standard MAD practice: pass the controller
Box( modifier: Modifier = Modifier
) {
// NavHost acts as the "Gallery Building"
NavHost(
navController = navController,
startDestination = AppDestinations.Tour.route, // Using your enum routes
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Red), .background(MaterialTheme.colorScheme.background)
contentAlignment = Alignment.Center
) { ) {
// Swaps the UI content based on the selected screen from the drawer // Destination: Tour (Gallery)
when (currentScreen) { composable(AppDestinations.Tour.route) {
AppDestinations.Tour -> GalleryScreen(state = uiState, modifier = Modifier) val galleryViewModel: GalleryViewModel = viewModel()
AppDestinations.Search -> SimplePlaceholder("Find Any Photos.") val uiState by galleryViewModel.uiState.collectAsState()
AppDestinations.Models -> SimplePlaceholder("Models Screen: Manage your LoRA/embeddings.")
AppDestinations.Inventory -> SimplePlaceholder("Inventory Screen: View all collected data.") GalleryScreen(state = uiState)
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.")
} }
// Destination: Inventory (Manage Photos)
composable(AppDestinations.Inventory.route) {
// New ViewModel scoped to this specific screen package
val manageViewModel: ManagePhotosViewModel = viewModel()
val manageState by manageViewModel.uiState.collectAsState()
ManagePhotosScreen(
state = manageState,
onCleanUpClick = { manageViewModel.performCleanUp() },
onCountTagsClick = { manageViewModel.countByTag() },
onStatsClick = { manageViewModel.loadStats() }
)
}
// Placeholders for other routes
composable(AppDestinations.Search.route) { SimplePlaceholder("Find Any Photos.") }
composable(AppDestinations.Settings.route) { SimplePlaceholder("Settings Screen.") }
// ... add other destinations similarly
} }
} }
@Composable @Composable
private fun SimplePlaceholder(text: String) { private fun SimplePlaceholder(text: String) {
Text( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
text = text, Text(
style = MaterialTheme.typography.titleLarge, text = text,
modifier = Modifier.padding(16.dp).background(color = Color.Magenta) style = MaterialTheme.typography.titleLarge,
) modifier = Modifier
.padding(16.dp)
.background(color = Color.Magenta.copy(alpha = 0.2f))
)
}
} }

View File

@@ -0,0 +1,74 @@
// In presentation/MainScreen.kt
package com.placeholder.sherpai2.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.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
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() {
// 1. The 'Tour Guide' (NavController) replaces the 'currentScreen' state
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
// 2. Observe the backstack to determine the current screen for the UI (TopBar/Drawer)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: AppDestinations.Search.route
// Find the current destination object based on the route string
val currentScreen = AppDestinations.values().find { it.route == currentRoute } ?: AppDestinations.Search
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawerContent(
currentScreen = currentScreen,
onDestinationClicked = { destination ->
// 3. Best Practice: Navigate with state restoration
navController.navigate(destination.route) {
// Pop up to the start destination to avoid building a huge backstack
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when re-selecting
launchSingleTop = true
// Restore state when re-selecting a previously selected item
restoreState = true
}
scope.launch { drawerState.close() }
}
)
},
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(currentScreen.label) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Open Drawer")
}
}
)
}
) { paddingValues ->
// 4. Pass the navController to the Content Area
MainContentArea(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}

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,124 @@
package com.placeholder.sherpai2.ui.screens.managephotos
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.repo.PhotoRepository
import com.placeholder.sherpai2.ui.screens.tourscreen.GalleryUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.Label
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
// Change this line to include '.navigation'
@Composable
fun ManagePhotosScreen(
state: ManagePhotosUiState,
onCleanUpClick: () -> Unit,
onCountTagsClick: () -> Unit,
onStatsClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Manage Photos",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 24.dp)
)
// --- Top Action Buttons Row ---
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ManageActionButton(
text = "Clean Up",
icon = Icons.Default.CleaningServices,
modifier = Modifier.weight(1f),
onClick = onCleanUpClick
)
ManageActionButton(
text = "Count by Tag",
icon = Icons.Default.Label,
modifier = Modifier.weight(1f),
onClick = onCountTagsClick
)
ManageActionButton(
text = "Stats",
icon = Icons.Default.Analytics,
modifier = Modifier.weight(1f),
onClick = onStatsClick
)
}
Spacer(modifier = Modifier.height(32.dp))
// --- Dynamic Content Area (Based on State) ---
Box(modifier = Modifier.fillMaxSize()) {
when (state) {
is ManagePhotosUiState.Idle -> {
Text("Select an action above to begin.", Modifier.align(Alignment.Center))
}
is ManagePhotosUiState.Scanning -> {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
is ManagePhotosUiState.Success -> {
DuplicateList(state.duplicateGroups)
}
is ManagePhotosUiState.Error -> {
}
is ManagePhotosUiState.Loading -> {
}
}
}
}
}
@Composable
fun ManageActionButton(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
OutlinedButton(
onClick = onClick,
modifier = modifier.height(80.dp),
shape = MaterialTheme.shapes.medium,
contentPadding = PaddingValues(8.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = null)
Spacer(Modifier.height(4.dp))
Text(text = text, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
fun DuplicateList(duplicates: Map<String, List<*>>) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
item { Text("Found ${duplicates.size} Duplicate Groups", style = MaterialTheme.typography.titleMedium) }
// We will build out the actual GroupCard next
}
}

View File

@@ -0,0 +1,20 @@
package com.placeholder.sherpai2.ui.screens.managephotos
import com.placeholder.sherpai2.data.photos.Photo
sealed class ManagePhotosUiState {
object Idle : ManagePhotosUiState()
object Scanning : ManagePhotosUiState()
object Loading : ManagePhotosUiState()
data class Success(
val duplicateGroups: Map<String, List<Photo>> = emptyMap(),
val stats: PhotoStats? = null
) : ManagePhotosUiState()
data class Error(val message: String) : ManagePhotosUiState()
}
data class PhotoStats(
val totalPhotos: Int,
val totalSize: Long,
val tagCounts: Map<String, Int>
)

View File

@@ -0,0 +1,65 @@
package com.placeholder.sherpai2.ui.screens.managephotos
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.data.repo.PhotoRepository
import com.placeholder.sherpai2.domain.PhotoDuplicateScanner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ManagePhotosViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PhotoRepository(application)
private val scanner = PhotoDuplicateScanner(application)
private val _uiState = MutableStateFlow<ManagePhotosUiState>(ManagePhotosUiState.Idle)
val uiState: StateFlow<ManagePhotosUiState> = _uiState.asStateFlow()
fun performCleanUp() {
viewModelScope.launch {
_uiState.value = ManagePhotosUiState.Loading
// Use the existing method from your PhotoRepository
val result = repository.scanExternalStorage()
// Handle the Result type properly
val allPhotos = result.getOrNull() ?: emptyList()
// Pass the List<Photo> to the scanner
val duplicates = scanner.findDuplicates(allPhotos)
// Update state with the correct property name: duplicateGroups
_uiState.value = ManagePhotosUiState.Success(duplicateGroups = duplicates)
}
}
fun countByTag() {
// Logic for tagging implementation goes here
}
fun loadStats() {
viewModelScope.launch {
_uiState.value = ManagePhotosUiState.Loading
val result = repository.scanExternalStorage()
val allPhotos: List<Photo> = result.getOrNull() ?: emptyList()
// sumOf now works because allPhotos is a List<Photo>, not Unit
val totalSize = allPhotos.sumOf { it.size }
_uiState.value = ManagePhotosUiState.Success(
stats = PhotoStats(
totalPhotos = allPhotos.size,
totalSize = totalSize,
tagCounts = emptyMap() // Implement tagging logic later
)
)
}
}
}

View File

@@ -1,6 +1,7 @@
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,7 +17,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.placeholder.sherpai2.data.photos.Photo import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState import com.placeholder.sherpai2.ui.screens.tourscreen.GalleryUiState
@@ -24,33 +25,38 @@ import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
@Composable @Composable
fun GalleryScreen( fun GalleryScreen(
state: GalleryUiState, state: GalleryUiState,
modifier: Modifier = Modifier // Add default modifier modifier: Modifier = Modifier
) { ) {
// Note: If this is inside MainContentArea, you might not need a second Scaffold. // Column ensures the Header and the Grid are stacked vertically
// Let's use a Column or Box to ensure it fills the space correctly. Column(modifier = modifier.fillMaxSize()) {
Column(
modifier = modifier.fillMaxSize()
) {
Text( Text(
text = "Photo Gallery", text = "Photo Gallery",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier.weight(1f), // Fills remaining space, allowing the grid to scroll
contentAlignment = Alignment.Center
) {
when (state) { when (state) {
is GalleryUiState.Loading -> CircularProgressIndicator() is GalleryUiState.Loading -> CircularProgressIndicator()
is GalleryUiState.Error -> Text(text = state.message) is GalleryUiState.Error -> Text(text = state.message, color = MaterialTheme.colorScheme.error)
is GalleryUiState.Success -> { is GalleryUiState.Success -> {
if (state.photos.isEmpty()) { if (state.photos.isEmpty()) {
Text("No photos found. Try adding some to the emulator!") Text("No photos found.")
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// contentPadding prevents the bottom items from being cut off by the navigation bar
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
items(state.photos) { photo -> // Using photo.uri as the key for better scroll performance
items(state.photos, key = { it.uri }) { photo ->
PhotoItem(photo) PhotoItem(photo)
} }
} }

View File

@@ -1,4 +1,4 @@
package com.placeholder.sherpai2.ui.tourscreen package com.placeholder.sherpai2.ui.screens.tourscreen
import com.placeholder.sherpai2.data.photos.Photo import com.placeholder.sherpai2.data.photos.Photo

View File

@@ -1,10 +1,8 @@
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.data.repo.PhotoRepository import com.placeholder.sherpai2.data.repo.PhotoRepository
import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState import com.placeholder.sherpai2.ui.screens.tourscreen.GalleryUiState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View File

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

View File

@@ -0,0 +1,44 @@
package com.placeholder.sherpai2.ui.screens.trainscreen
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.repo.FaceRepository
import com.placeholder.sherpai2.domain.faces.analyzer.FaceAnalyzer
import kotlinx.coroutines.launch
@Composable
fun ImagePickerScreen(
viewModel: ImagePickerViewModel
) {
val selectedImages by viewModel.selectedImages
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris: List<Uri> ->
viewModel.onImagesSelected(uris)
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Button(onClick = { launcher.launch("image/*") }) {
Text("Select Photos")
}
Spacer(modifier = Modifier.height(16.dp))
LazyColumn {
items(selectedImages) { uri ->
Text(uri.toString(), style = MaterialTheme.typography.bodyMedium)
}
}
}
}

View File

@@ -0,0 +1,67 @@
package com.placeholder.sherpai2.ui.screens.trainscreen
import android.net.Uri
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.repo.FaceRepository
import com.placeholder.sherpai2.domain.faces.analyzer.FaceAnalyzer
import kotlinx.coroutines.launch
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class ImagePickerViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val faceAnalyzer: FaceAnalyzer,
private val repository: FaceRepository
) : ViewModel() {
private val _selectedImages = mutableStateOf<List<Uri>>(emptyList())
val selectedImages: State<List<Uri>> = _selectedImages
fun onImagesSelected(uris: List<Uri>) {
_selectedImages.value = uris.take(10)
viewModelScope.launch {
uris.take(10).forEach { uri ->
val bitmap = loadBitmapFromUri(uri)
val embedding = faceAnalyzer.analyze(bitmap)
val label = uri.lastPathSegment ?: "Unknown"
repository.saveFace(label, embedding)
}
}
}
private suspend fun loadBitmapFromUri(uri: Uri): Bitmap =
withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val source = ImageDecoder.createSource(
appContext.contentResolver,
uri
)
ImageDecoder.decodeBitmap(source)
} else {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(
appContext.contentResolver,
uri
)
}
}
}

View File

@@ -0,0 +1,2 @@
package com.placeholder.sherpai2.ui.screens.trainscreen

View File

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

View File

@@ -4,3 +4,9 @@ plugins {
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
} }
buildscript {
dependencies {
classpath("com.android.tools.build:gradle:8.13.2")
}
}

View File

@@ -0,0 +1,65 @@
[versions]
activityComposeVersion = "1.8.2"
agp = "8.13.1"
coreKtxVersion = "1.12.0"
kotlin = "2.0.21"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.1"
composeBom = "2024.09.00"
materialIconsExtended = "1.6.0"
navigationRuntimeKtx = "2.9.6"
navigationCompose = "2.9.6"
#Branch Updated -dev_backend_one
ksp = "2.0.21-1.0.28" # MUST match the 2.0.21 prefix
room = "2.6.1"
camerax = "1.4.0"
[libraries]
androidx-activity-compose-v182 = { module = "androidx.activity:activity-compose", version.ref = "activityComposeVersion" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-ktx-v1120 = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
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" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
#Branch Updated -dev_backend_one
# CameraX stack
camera-core = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
# ML Stack
mlkit-face = { group = "com.google.mlkit", name = "face-detection", version = "16.1.7" }
tflite = { group = "org.tensorflow", name = "tensorflow-lite", version = "2.16.1" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

@@ -1,32 +1,66 @@
[versions] [versions]
agp = "8.13.1" # Tooling
agp = "8.7.3" # Latest stable for 2025
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.17.0" ksp = "2.0.21-1.0.28" # Strictly matched to Kotlin version
junit = "4.13.2"
junitVersion = "1.3.0" # AndroidX / Compose
espressoCore = "3.7.0" androidx-core = "1.15.0"
lifecycleRuntimeKtx = "2.10.0" androidx-lifecycle = "2.10.0"
activityCompose = "1.12.1" androidx-activity = "1.12.2"
composeBom = "2024.09.00" compose-bom = "2025.12.00"
navigation = "2.8.5"
# Camera & ML
camerax = "1.5.2"
mlkit-face = "16.1.7"
tflite = "2.16.1"
# Database & DI
room = "2.7.0-alpha01"
hilt = "2.54" # Supports KSP2 and fixes JavaPoet conflicts
# Images
coil = "2.6.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } # Core & Lifecycle
junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } # Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } # CameraX
camera-core = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
# ML
mlkit-face = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkit-face" }
tflite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tflite" }
tflite-support = { group = "org.tensorflow", name = "tensorflow-lite-support", version = "0.4.4" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Images
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# 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" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }