4 Commits

Author SHA1 Message Date
genki
49e6523f75 Adding HILT DI and First Pass at backend Kotlin 2025-12-24 10:18:46 -05:00
genki
c10cbf373f Working Gallery and Repo - Earlydays! 2025-12-20 18:27:09 -05:00
genki
91f6327c31 CheckPoint save for adding 'Tour' screen, and PhotoData and PhotoViewModels 2025-12-20 18:27:09 -05:00
genki
52fa755a3f Working Gallery and Repo - Earlydays! 2025-12-20 17:57:01 -05:00
40 changed files with 1291 additions and 257 deletions

1
.gitignore vendored
View File

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

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
SherpAI2

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

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

View File

@@ -23,7 +23,9 @@ import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
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.util.Log
import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.domain.PhotoDuplicateScanner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@@ -45,6 +46,13 @@ class PhotoRepository(private val context: Context) {
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())
}

View File

@@ -1,7 +1,19 @@
package com.placeholder.sherpai2.domain
import android.content.Context
import com.placeholder.sherpai2.data.photos.Photo
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
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.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
/**
* 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
object Tour : AppDestinations(
route = "Tour",
icon = Icons.Default.PhotoLibrary,
label = "Tour"
)
object Search : AppDestinations("search", Icons.Default.Search, "Search")
object Models : AppDestinations("models", Icons.Default.Layers, "Models")
object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv")
object Train : AppDestinations("train", Icons.Default.TrackChanges, "Train")
object Tags : AppDestinations("tags", Icons.Default.LocalOffer, "Tags")
Tour("tour", Icons.Default.PhotoLibrary, "Tour"),
Search("search", Icons.Default.Search, "Search"),
Models("models", Icons.Default.Layers, "Models"),
Inventory("inv", Icons.Default.Inventory2, "Inventory"),
Train("train", Icons.Default.TrackChanges, "Train"),
Tags("tags", Icons.Default.LocalOffer, "Tags"),
// Utility/Secondary Sections
object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload")
object Settings : AppDestinations("settings", Icons.Default.Settings, "Settings")
Upload("upload", Icons.Default.CloudUpload, "Upload"),
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.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
// Header Area
Text(
"SherpAI Control Panel",
text = "SherpAI Control Panel",
style = MaterialTheme.typography.headlineSmall,
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)) {
mainDrawerItems.forEach { destination ->
AppDestinations.mainDrawerItems.forEach { destination ->
NavigationDrawerItem(
label = { Text(destination.label) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
@@ -41,11 +38,15 @@ fun AppDrawerContent(
}
// 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)) {
utilityDrawerItems.forEach { destination ->
AppDestinations.utilityDrawerItems.forEach { destination ->
NavigationDrawerItem(
label = { Text(destination.label) },
icon = { Icon(destination.icon, contentDescription = destination.label) },

View File

@@ -1,12 +1,11 @@
// 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
@@ -14,43 +13,66 @@ 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.graphics.Color
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.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
fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier, galleryViewModel: GalleryViewModel = viewModel() ) {
val uiState by galleryViewModel.uiState.collectAsState()
Box(
fun MainContentArea(
navController: NavHostController, // Standard MAD practice: pass the controller
modifier: Modifier = Modifier
) {
// NavHost acts as the "Gallery Building"
NavHost(
navController = navController,
startDestination = AppDestinations.Tour.route, // Using your enum routes
modifier = modifier
.fillMaxSize()
.background(Color.Red),
contentAlignment = Alignment.Center
.background(MaterialTheme.colorScheme.background)
) {
// 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.")
// Destination: Tour (Gallery)
composable(AppDestinations.Tour.route) {
val galleryViewModel: GalleryViewModel = viewModel()
val uiState by galleryViewModel.uiState.collectAsState()
GalleryScreen(state = uiState)
}
// 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
private fun SimplePlaceholder(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp).background(color = Color.Magenta)
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,7 +17,7 @@ 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
import com.placeholder.sherpai2.ui.screens.tourscreen.GalleryUiState
@@ -24,33 +25,38 @@ import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
@Composable
fun GalleryScreen(
state: GalleryUiState,
modifier: Modifier = Modifier // Add default modifier
modifier: Modifier = 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()
) {
// Column ensures the Header and the Grid are stacked vertically
Column(modifier = modifier.fillMaxSize()) {
Text(
text = "Photo Gallery",
style = MaterialTheme.typography.headlineMedium,
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) {
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 -> {
if (state.photos.isEmpty()) {
Text("No photos found. Try adding some to the emulator!")
Text("No photos found.")
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
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),
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)
}
}

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

View File

@@ -1,10 +1,8 @@
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 com.placeholder.sherpai2.ui.screens.tourscreen.GalleryUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
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.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]
agp = "8.13.1"
# Tooling
agp = "8.7.3" # Latest stable for 2025
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"
ksp = "2.0.21-1.0.28" # Strictly matched to Kotlin version
# AndroidX / Compose
androidx-core = "1.15.0"
androidx-lifecycle = "2.10.0"
androidx-activity = "1.12.2"
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]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
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" }
# Core & Lifecycle
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity" }
# Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# 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]
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" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }