diff --git a/.gitignore b/.gitignore index aa724b7..d4c3a57 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +/.idea/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4044103..6e33dca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } } +// FIX for hiltAggregateDepsDebug: Correctly configure the Hilt extension +hilt { + enableAggregatingTask = false +} + 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 + // 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) - // --- 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 + // 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) - // --- 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") + // Room (KSP) + implementation(libs.room.runtime) + ksp(libs.room.compiler) - // --- 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") + // Images + implementation(libs.coil.compose) - // --- 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") + // Hilt (KSP) - Fixed by removing kapt and using ksp + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4f001a..ce43118 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> + + @Query("SELECT * FROM faces WHERE label = :label LIMIT 1") + suspend fun getFaceByLabel(label: String): FaceEntity? + + @Query("DELETE FROM faces") + suspend fun clearAll() +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/FaceDatabase.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/FaceDatabase.kt new file mode 100644 index 0000000..3872b0b --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/FaceDatabase.kt @@ -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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/FaceEntity.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/FaceEntity.kt new file mode 100644 index 0000000..b9d09ee --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/FaceEntity.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/repo/FaceRepository.kt b/app/src/main/java/com/placeholder/sherpai2/data/repo/FaceRepository.kt new file mode 100644 index 0000000..eb89e93 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/data/repo/FaceRepository.kt @@ -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 = + 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 +) diff --git a/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt b/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt index d6286cb..c926492 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/repo/PhotoRepository.kt @@ -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()) } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt b/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt index 18bf55b..f46c55d 100644 --- a/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt +++ b/app/src/main/java/com/placeholder/sherpai2/domain/PhotoScanner.kt @@ -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): Map> { + // 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 + } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/faces/FaceAnalyzer.kt b/app/src/main/java/com/placeholder/sherpai2/domain/faces/FaceAnalyzer.kt new file mode 100644 index 0000000..2ab0dae --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/faces/FaceAnalyzer.kt @@ -0,0 +1,2 @@ +package com.placeholder.sherpai2.domain.faces + diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/faces/analyzer/FaceAnalyzer.kt b/app/src/main/java/com/placeholder/sherpai2/domain/faces/analyzer/FaceAnalyzer.kt new file mode 100644 index 0000000..d38b228 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/faces/analyzer/FaceAnalyzer.kt @@ -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 = + 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 { + 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() + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/faces/ml/FaceNetInterpreter.kt b/app/src/main/java/com/placeholder/sherpai2/domain/faces/ml/FaceNetInterpreter.kt new file mode 100644 index 0000000..cf3f99b --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/faces/ml/FaceNetInterpreter.kt @@ -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() + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/faces/ml/ModelConstants.kt b/app/src/main/java/com/placeholder/sherpai2/domain/faces/ml/ModelConstants.kt new file mode 100644 index 0000000..a3e1e68 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/faces/ml/ModelConstants.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/util/EmbeddingMath.kt.kt b/app/src/main/java/com/placeholder/sherpai2/domain/util/EmbeddingMath.kt.kt new file mode 100644 index 0000000..1a4d48e --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/util/EmbeddingMath.kt.kt @@ -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) + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt index ab4baf3..9c12b95 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt @@ -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"); -// 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 -) \ No newline at end of file + companion object { + // High-level grouping for the Drawer UI + val mainDrawerItems = listOf(Tour, Search, Models, Inventory, Train, Tags) + val utilityDrawerItems = listOf(Upload, Settings) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/MainScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/MainScreen.kt deleted file mode 100644 index 50ce1ce..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/MainScreen.kt +++ /dev/null @@ -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) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt index 53d2d25..440b27f 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt @@ -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) }, diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainContentArea.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainContentArea.kt index 51541a3..6940b49 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainContentArea.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainContentArea.kt @@ -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)) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt new file mode 100644 index 0000000..fc11e3e --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/PhotoListScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/PhotoListScreen.kt deleted file mode 100644 index 18815ed..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/PhotoListScreen.kt +++ /dev/null @@ -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 -) { - 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) - ) - } - } -} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosScreen.kt new file mode 100644 index 0000000..4af63ec --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosScreen.kt @@ -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>) { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosUiState.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosUiState.kt new file mode 100644 index 0000000..3fd3371 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosUiState.kt @@ -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> = 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 +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosViewModel.kt new file mode 100644 index 0000000..249b4cc --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/managephotos/ManagePhotosViewModel.kt @@ -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.Idle) + val uiState: StateFlow = _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 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 = result.getOrNull() ?: emptyList() + + // sumOf now works because allPhotos is a List, 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 + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryScreen.kt similarity index 70% rename from app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryScreen.kt rename to app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryScreen.kt index a101cff..fcec6dd 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryScreen.kt @@ -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) } } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryUiState.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryUiState.kt similarity index 81% rename from app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryUiState.kt rename to app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryUiState.kt index 78f8dc2..702d6fe 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryUiState.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryUiState.kt @@ -1,4 +1,4 @@ -package com.placeholder.sherpai2.ui.tourscreen +package com.placeholder.sherpai2.ui.screens.tourscreen import com.placeholder.sherpai2.data.photos.Photo diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryViewModel.kt similarity index 87% rename from app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryViewModel.kt rename to app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryViewModel.kt index c521a17..4ea1923 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/GalleryViewModel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/GalleryViewModel.kt @@ -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 diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/components/AlbumBox.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/components/AlbumBox.kt new file mode 100644 index 0000000..e83386e --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/tourscreen/components/AlbumBox.kt @@ -0,0 +1,4 @@ +package com.placeholder.sherpai2.ui.screens.tourscreen.components + +class AlbumBox { +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/ImagePickerScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/ImagePickerScreen.kt new file mode 100644 index 0000000..23b9698 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/ImagePickerScreen.kt @@ -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 -> + 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) + } + } + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/ImagePickerViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/ImagePickerViewModel.kt new file mode 100644 index 0000000..32dc207 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/ImagePickerViewModel.kt @@ -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>(emptyList()) + val selectedImages: State> = _selectedImages + + fun onImagesSelected(uris: List) { + _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 + ) + } + } +} diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/TrainModelScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/TrainModelScreen.kt new file mode 100644 index 0000000..d4ed250 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/screens/trainscreen/TrainModelScreen.kt @@ -0,0 +1,2 @@ +package com.placeholder.sherpai2.ui.screens.trainscreen + diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/components/AlbumBox.kt b/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/components/AlbumBox.kt deleted file mode 100644 index 1b376bc..0000000 --- a/app/src/main/java/com/placeholder/sherpai2/ui/tourscreen/components/AlbumBox.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.placeholder.sherpai2.ui.tourscreen.components - -class AlbumBox { -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..27ffab1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,10 @@ plugins { alias(libs.plugins.android.application) apply false 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") + } } \ No newline at end of file diff --git a/gradle/bklibs.versions.toml b/gradle/bklibs.versions.toml new file mode 100644 index 0000000..29b49b6 --- /dev/null +++ b/gradle/bklibs.versions.toml @@ -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" } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2701157..5f5f2de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file