2 Commits

Author SHA1 Message Date
genki
0e388e9a64 Working Gallery and Repo - Earlydays! 2025-12-20 17:54:16 -05:00
genki
16eecf6c08 CheckPoint save for adding 'Tour' screen, and PhotoData and PhotoViewModels 2025-12-20 11:39:46 -05:00
40 changed files with 255 additions and 1289 deletions

1
.gitignore vendored
View File

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

1
.idea/.name generated
View File

@@ -1 +0,0 @@
SherpAI2

View File

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

View File

@@ -10,8 +10,7 @@
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,9 +23,7 @@ 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

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

View File

@@ -1,58 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,90 +0,0 @@
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,7 +7,6 @@ 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
@@ -46,13 +45,6 @@ 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,19 +1,7 @@
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

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

View File

@@ -1,113 +0,0 @@
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

@@ -1,111 +0,0 @@
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

@@ -1,27 +0,0 @@
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

@@ -1,59 +0,0 @@
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,33 +1,42 @@
// 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.
*/ */
enum class AppDestinations(val route: String, val icon: ImageVector, val label: String) { sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) {
// Core Functional Sections // Core Functional Sections
Tour("tour", Icons.Default.PhotoLibrary, "Tour"), object Tour : AppDestinations(
Search("search", Icons.Default.Search, "Search"), route = "Tour",
Models("models", Icons.Default.Layers, "Models"), icon = Icons.Default.PhotoLibrary,
Inventory("inv", Icons.Default.Inventory2, "Inventory"), label = "Tour"
Train("train", Icons.Default.TrackChanges, "Train"), )
Tags("tags", Icons.Default.LocalOffer, "Tags"), object Search : AppDestinations("search", Icons.Default.Search, "Search")
object Models : AppDestinations("models", Icons.Default.Layers, "Models")
object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv")
object Train : AppDestinations("train", Icons.Default.TrackChanges, "Train")
object Tags : AppDestinations("tags", Icons.Default.LocalOffer, "Tags")
// Utility/Secondary Sections // Utility/Secondary Sections
Upload("upload", Icons.Default.CloudUpload, "Upload"), object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload")
Settings("settings", Icons.Default.Settings, "Settings"); object Settings : AppDestinations("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

@@ -0,0 +1,63 @@
// 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,26 +7,29 @@ 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 Area // Header/Logo Area
Text( Text(
text = "SherpAI Control Panel", "SherpAI Control Panel",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
Divider(Modifier.fillMaxWidth())
HorizontalDivider(modifier = Modifier.fillMaxWidth()) // 1. Main Navigation Items
// 1. Main Navigation Items (Referencing the Companion Object)
Column(modifier = Modifier.padding(vertical = 8.dp)) { Column(modifier = Modifier.padding(vertical = 8.dp)) {
AppDestinations.mainDrawerItems.forEach { destination -> 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) },
@@ -38,15 +41,11 @@ fun AppDrawerContent(
} }
// Separator // Separator
HorizontalDivider( Divider(Modifier.fillMaxWidth().padding(vertical = 8.dp))
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
// 2. Utility Items (Referencing the Companion Object) // 2. Utility Items
Column(modifier = Modifier.padding(vertical = 8.dp)) { Column(modifier = Modifier.padding(vertical = 8.dp)) {
AppDestinations.utilityDrawerItems.forEach { destination -> 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,11 +1,12 @@
// 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
@@ -13,66 +14,43 @@ 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.graphics.Color import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.navigation.AppDestinations
import com.placeholder.sherpai2.ui.screens.managephotos.ManagePhotosScreen import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import com.placeholder.sherpai2.ui.screens.managephotos.ManagePhotosViewModel import androidx.lifecycle.viewmodel.compose.viewModel
@Composable @Composable
fun MainContentArea( fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier, galleryViewModel: GalleryViewModel = viewModel() ) {
navController: NavHostController, // Standard MAD practice: pass the controller val uiState by galleryViewModel.uiState.collectAsState()
modifier: Modifier = Modifier Box(
) {
// NavHost acts as the "Gallery Building"
NavHost(
navController = navController,
startDestination = AppDestinations.Tour.route, // Using your enum routes
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(Color.Red),
contentAlignment = Alignment.Center
) { ) {
// Destination: Tour (Gallery) // Swaps the UI content based on the selected screen from the drawer
composable(AppDestinations.Tour.route) { when (currentScreen) {
val galleryViewModel: GalleryViewModel = viewModel() AppDestinations.Tour -> GalleryScreen(state = uiState, modifier = Modifier)
val uiState by galleryViewModel.uiState.collectAsState() AppDestinations.Search -> SimplePlaceholder("Find Any Photos.")
AppDestinations.Models -> SimplePlaceholder("Models Screen: Manage your LoRA/embeddings.")
GalleryScreen(state = uiState) 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: 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) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier modifier = Modifier.padding(16.dp).background(color = Color.Magenta)
.padding(16.dp)
.background(color = Color.Magenta.copy(alpha = 0.2f))
) )
}
} }

View File

@@ -1,74 +0,0 @@
// 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

@@ -0,0 +1,34 @@
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

@@ -1,124 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,65 +0,0 @@
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,4 +0,0 @@
package com.placeholder.sherpai2.ui.screens.tourscreen.components
class AlbumBox {
}

View File

@@ -1,44 +0,0 @@
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

@@ -1,67 +0,0 @@
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

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

View File

@@ -1,7 +1,6 @@
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
@@ -17,7 +16,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.screens.tourscreen.GalleryUiState import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
@@ -25,38 +24,33 @@ import com.placeholder.sherpai2.ui.screens.tourscreen.GalleryUiState
@Composable @Composable
fun GalleryScreen( fun GalleryScreen(
state: GalleryUiState, state: GalleryUiState,
modifier: Modifier = Modifier modifier: Modifier = Modifier // Add default modifier
) { ) {
// Column ensures the Header and the Grid are stacked vertically // Note: If this is inside MainContentArea, you might not need a second Scaffold.
Column(modifier = modifier.fillMaxSize()) { // Let's use a Column or Box to ensure it fills the space correctly.
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, color = MaterialTheme.colorScheme.error) is GalleryUiState.Error -> Text(text = state.message)
is GalleryUiState.Success -> { is GalleryUiState.Success -> {
if (state.photos.isEmpty()) { if (state.photos.isEmpty()) {
Text("No photos found.") Text("No photos found. Try adding some to the emulator!")
} 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)
) { ) {
// Using photo.uri as the key for better scroll performance items(state.photos) { photo ->
items(state.photos, key = { it.uri }) { photo ->
PhotoItem(photo) PhotoItem(photo)
} }
} }

View File

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

View File

@@ -1,8 +1,10 @@
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.screens.tourscreen.GalleryUiState import com.placeholder.sherpai2.ui.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.tourscreen.components
class AlbumBox {
}

View File

@@ -4,9 +4,3 @@ 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

@@ -1,65 +0,0 @@
[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,66 +1,32 @@
[versions] [versions]
# Tooling agp = "8.13.1"
agp = "8.7.3" # Latest stable for 2025
kotlin = "2.0.21" kotlin = "2.0.21"
ksp = "2.0.21-1.0.28" # Strictly matched to Kotlin version coreKtx = "1.17.0"
junit = "4.13.2"
# AndroidX / Compose junitVersion = "1.3.0"
androidx-core = "1.15.0" espressoCore = "3.7.0"
androidx-lifecycle = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
androidx-activity = "1.12.2" activityCompose = "1.12.1"
compose-bom = "2025.12.00" composeBom = "2024.09.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]
# Core & Lifecycle androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity" } 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" }
# Compose androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } 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" }
# CameraX androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
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" }