diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml index aa984b0..37c69e4 100644 --- a/.idea/deviceManager.xml +++ b/.idea/deviceManager.xml @@ -1,28 +1,6 @@ - diff --git a/app/PersonEntity b/app/PersonEntity deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt index 5773ab3..d2ce09b 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt @@ -2,32 +2,22 @@ package com.placeholder.sherpai2.data.local import androidx.room.Database import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.room.migration.Migration import com.placeholder.sherpai2.data.local.dao.* import com.placeholder.sherpai2.data.local.entity.* /** * AppDatabase - Complete database for SherpAI2 * - * VERSION 7 - Added face detection cache to ImageEntity: - * - hasFaces: Boolean? - * - faceCount: Int? - * - facesLastDetected: Long? - * - faceDetectionVersion: Int? + * VERSION 8 - PHASE 2: Multi-centroid face models + age tagging + * - Added PersonEntity.isChild, siblingIds, familyGroupId + * - Changed FaceModelEntity.embedding → centroidsJson (multi-centroid) + * - Added PersonAgeTagEntity table for searchable age tags * - * ENTITIES: - * - YOUR EXISTING: Image, Tag, Event, junction tables - * - NEW: PersonEntity (people in your app) - * - NEW: FaceModelEntity (face embeddings, links to PersonEntity) - * - NEW: PhotoFaceTagEntity (face detections, links to ImageEntity + FaceModelEntity) - * - * DEV MODE: Using destructive migration (fallbackToDestructiveMigration) - * - Fresh install on every schema change - * - No manual migrations needed during development - * - * PRODUCTION MODE: Add proper migrations before release - * - See DatabaseMigration.kt for migration code - * - Remove fallbackToDestructiveMigration() - * - Add .addMigrations(MIGRATION_6_7) + * MIGRATION STRATEGY: + * - Development: fallbackToDestructiveMigration (fresh install) + * - Production: Add MIGRATION_7_8 before release */ @Database( entities = [ @@ -42,16 +32,16 @@ import com.placeholder.sherpai2.data.local.entity.* PersonEntity::class, FaceModelEntity::class, PhotoFaceTagEntity::class, + PersonAgeTagEntity::class, // NEW: Age tagging // ===== COLLECTIONS ===== CollectionEntity::class, CollectionImageEntity::class, CollectionFilterEntity::class ], - version = 7, // INCREMENTED for face detection cache + version = 8, // INCREMENTED for Phase 2 exportSchema = false ) -// No TypeConverters needed - embeddings stored as strings abstract class AppDatabase : RoomDatabase() { // ===== CORE DAOs ===== @@ -66,33 +56,111 @@ abstract class AppDatabase : RoomDatabase() { abstract fun personDao(): PersonDao abstract fun faceModelDao(): FaceModelDao abstract fun photoFaceTagDao(): PhotoFaceTagDao + abstract fun personAgeTagDao(): PersonAgeTagDao // NEW // ===== COLLECTIONS DAO ===== abstract fun collectionDao(): CollectionDao } /** - * MIGRATION NOTES FOR PRODUCTION: + * MIGRATION 7 → 8 (Phase 2) * - * When ready to ship to users, replace destructive migration with proper migration: + * Changes: + * 1. Add isChild, siblingIds, familyGroupId to persons table + * 2. Rename embedding → centroidsJson in face_models table + * 3. Create person_age_tags table + */ +val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + + // ===== STEP 1: Update persons table ===== + database.execSQL("ALTER TABLE persons ADD COLUMN isChild INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE persons ADD COLUMN siblingIds TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE persons ADD COLUMN familyGroupId TEXT DEFAULT NULL") + + // Create index on familyGroupId for sibling queries + database.execSQL("CREATE INDEX IF NOT EXISTS index_persons_familyGroupId ON persons(familyGroupId)") + + // ===== STEP 2: Update face_models table ===== + // Rename embedding column to centroidsJson + // SQLite doesn't support RENAME COLUMN directly, so we need to: + // 1. Create new table with new schema + // 2. Copy data (converting single embedding to centroid JSON) + // 3. Drop old table + // 4. Rename new table + + // Create new table + database.execSQL(""" + CREATE TABLE IF NOT EXISTS face_models_new ( + id TEXT PRIMARY KEY NOT NULL, + personId TEXT NOT NULL, + centroidsJson TEXT NOT NULL, + trainingImageCount INTEGER NOT NULL, + averageConfidence REAL NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + lastUsed INTEGER, + isActive INTEGER NOT NULL, + FOREIGN KEY(personId) REFERENCES persons(id) ON DELETE CASCADE + ) + """) + + // Copy data, converting embedding to centroidsJson format + // This converts single embedding to a list with one centroid + database.execSQL(""" + INSERT INTO face_models_new + SELECT + id, + personId, + '[{"embedding":' || REPLACE(REPLACE(embedding, ',', ','), ',', ',') || ',"effectiveTimestamp":' || createdAt || ',"ageAtCapture":null,"photoCount":' || trainingImageCount || ',"timeRangeMonths":12,"avgConfidence":' || averageConfidence || '}]' as centroidsJson, + trainingImageCount, + averageConfidence, + createdAt, + updatedAt, + lastUsed, + isActive + FROM face_models + """) + + // Drop old table + database.execSQL("DROP TABLE face_models") + + // Rename new table + database.execSQL("ALTER TABLE face_models_new RENAME TO face_models") + + // Recreate index + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_face_models_personId ON face_models(personId)") + + // ===== STEP 3: Create person_age_tags table ===== + database.execSQL(""" + CREATE TABLE IF NOT EXISTS person_age_tags ( + id TEXT PRIMARY KEY NOT NULL, + personId TEXT NOT NULL, + imageId TEXT NOT NULL, + ageAtCapture INTEGER NOT NULL, + tagValue TEXT NOT NULL, + confidence REAL NOT NULL, + createdAt INTEGER NOT NULL, + FOREIGN KEY(personId) REFERENCES persons(id) ON DELETE CASCADE, + FOREIGN KEY(imageId) REFERENCES images(imageId) ON DELETE CASCADE + ) + """) + + // Create indices for fast lookups + database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_personId ON person_age_tags(personId)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_imageId ON person_age_tags(imageId)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_ageAtCapture ON person_age_tags(ageAtCapture)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_tagValue ON person_age_tags(tagValue)") + } +} + +/** + * PRODUCTION MIGRATION NOTES: * - * val MIGRATION_6_7 = object : Migration(6, 7) { - * override fun migrate(database: SupportSQLiteDatabase) { - * // Add face detection cache columns - * database.execSQL("ALTER TABLE images ADD COLUMN hasFaces INTEGER DEFAULT NULL") - * database.execSQL("ALTER TABLE images ADD COLUMN faceCount INTEGER DEFAULT NULL") - * database.execSQL("ALTER TABLE images ADD COLUMN facesLastDetected INTEGER DEFAULT NULL") - * database.execSQL("ALTER TABLE images ADD COLUMN faceDetectionVersion INTEGER DEFAULT NULL") + * Before shipping to users, update DatabaseModule to use migration: * - * // Create indices - * database.execSQL("CREATE INDEX IF NOT EXISTS index_images_hasFaces ON images(hasFaces)") - * database.execSQL("CREATE INDEX IF NOT EXISTS index_images_faceCount ON images(faceCount)") - * } - * } - * - * Then in your database builder: - * Room.databaseBuilder(context, AppDatabase::class.java, "database_name") - * .addMigrations(MIGRATION_6_7) // Add this + * Room.databaseBuilder(context, AppDatabase::class.java, "sherpai.db") + * .addMigrations(MIGRATION_7_8) // Add this * // .fallbackToDestructiveMigration() // Remove this * .build() */ \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/Personagetagdao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/Personagetagdao.kt new file mode 100644 index 0000000..d5ae390 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/Personagetagdao.kt @@ -0,0 +1,104 @@ +package com.placeholder.sherpai2.data.local.dao + +import androidx.room.* +import com.placeholder.sherpai2.data.local.entity.PersonAgeTagEntity +import kotlinx.coroutines.flow.Flow + +/** + * PersonAgeTagDao - Manage searchable age tags for children + * + * USAGE EXAMPLES: + * - Search "emma age 3" → getImageIdsForTag("emma_age3") + * - Find all photos of Emma at age 5 → getImageIdsForPersonAtAge(emmaId, 5) + * - Get age progression → getTagsForPerson(emmaId) sorted by age + */ +@Dao +interface PersonAgeTagDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTag(tag: PersonAgeTagEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTags(tags: List) + + /** + * Get all age tags for a person (sorted by age) + * Useful for age progression timeline + */ + @Query("SELECT * FROM person_age_tags WHERE personId = :personId ORDER BY ageAtCapture ASC") + suspend fun getTagsForPerson(personId: String): List + + /** + * Get all age tags for an image + */ + @Query("SELECT * FROM person_age_tags WHERE imageId = :imageId") + suspend fun getTagsForImage(imageId: String): List + + /** + * Search by tag value (e.g., "emma_age3") + * Returns all image IDs matching this tag + */ + @Query("SELECT DISTINCT imageId FROM person_age_tags WHERE tagValue = :tagValue") + suspend fun getImageIdsForTag(tagValue: String): List + + /** + * Get images of a person at a specific age + */ + @Query("SELECT DISTINCT imageId FROM person_age_tags WHERE personId = :personId AND ageAtCapture = :age") + suspend fun getImageIdsForPersonAtAge(personId: String, age: Int): List + + /** + * Get images of a person in an age range + */ + @Query(""" + SELECT DISTINCT imageId FROM person_age_tags + WHERE personId = :personId + AND ageAtCapture BETWEEN :minAge AND :maxAge + ORDER BY ageAtCapture ASC + """) + suspend fun getImageIdsForPersonAgeRange(personId: String, minAge: Int, maxAge: Int): List + + /** + * Get all unique ages for a person (for age picker UI) + */ + @Query("SELECT DISTINCT ageAtCapture FROM person_age_tags WHERE personId = :personId ORDER BY ageAtCapture ASC") + suspend fun getAgesForPerson(personId: String): List + + /** + * Delete all tags for a person + */ + @Query("DELETE FROM person_age_tags WHERE personId = :personId") + suspend fun deleteTagsForPerson(personId: String) + + /** + * Delete all tags for an image + */ + @Query("DELETE FROM person_age_tags WHERE imageId = :imageId") + suspend fun deleteTagsForImage(imageId: String) + + /** + * Get count of photos at each age (for statistics) + */ + @Query(""" + SELECT ageAtCapture, COUNT(DISTINCT imageId) as count + FROM person_age_tags + WHERE personId = :personId + GROUP BY ageAtCapture + ORDER BY ageAtCapture ASC + """) + suspend fun getPhotoCountByAge(personId: String): List + + /** + * Flow version for reactive UI + */ + @Query("SELECT * FROM person_age_tags WHERE personId = :personId ORDER BY ageAtCapture ASC") + fun getTagsForPersonFlow(personId: String): Flow> +} + +/** + * Data class for age photo count statistics + */ +data class AgePhotoCount( + val ageAtCapture: Int, + val count: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Facerecognitionentities.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Facerecognitionentities.kt index 0bddef8..64a8e8e 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Facerecognitionentities.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Facerecognitionentities.kt @@ -5,19 +5,24 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import org.json.JSONArray +import org.json.JSONObject import java.util.UUID /** - * PersonEntity - NO DEFAULT VALUES for KSP compatibility + * PersonEntity - ENHANCED with child tracking and sibling relationships */ @Entity( tableName = "persons", - indices = [Index(value = ["name"])] + indices = [ + Index(value = ["name"]), + Index(value = ["familyGroupId"]) + ] ) data class PersonEntity( @PrimaryKey @ColumnInfo(name = "id") - val id: String, // ← No default + val id: String, @ColumnInfo(name = "name") val name: String, @@ -25,26 +30,48 @@ data class PersonEntity( @ColumnInfo(name = "dateOfBirth") val dateOfBirth: Long?, + @ColumnInfo(name = "isChild") + val isChild: Boolean, // NEW: Auto-set based on age + + @ColumnInfo(name = "siblingIds") + val siblingIds: String?, // NEW: JSON list ["uuid1", "uuid2"] + + @ColumnInfo(name = "familyGroupId") + val familyGroupId: String?, // NEW: UUID for family unit + @ColumnInfo(name = "relationship") val relationship: String?, @ColumnInfo(name = "createdAt") - val createdAt: Long, // ← No default + val createdAt: Long, @ColumnInfo(name = "updatedAt") - val updatedAt: Long // ← No default + val updatedAt: Long ) { companion object { fun create( name: String, dateOfBirth: Long? = null, + isChild: Boolean = false, + siblingIds: List = emptyList(), relationship: String? = null ): PersonEntity { val now = System.currentTimeMillis() + + // Create family group if siblings exist + val familyGroupId = if (siblingIds.isNotEmpty()) { + UUID.randomUUID().toString() + } else null + return PersonEntity( id = UUID.randomUUID().toString(), name = name, dateOfBirth = dateOfBirth, + isChild = isChild, + siblingIds = if (siblingIds.isNotEmpty()) { + JSONArray(siblingIds).toString() + } else null, + familyGroupId = familyGroupId, relationship = relationship, createdAt = now, updatedAt = now @@ -52,6 +79,17 @@ data class PersonEntity( } } + fun getSiblingIds(): List { + return if (siblingIds != null) { + try { + val jsonArray = JSONArray(siblingIds) + (0 until jsonArray.length()).map { jsonArray.getString(it) } + } catch (e: Exception) { + emptyList() + } + } else emptyList() + } + fun getAge(): Int? { if (dateOfBirth == null) return null val now = System.currentTimeMillis() @@ -74,7 +112,7 @@ data class PersonEntity( } /** - * FaceModelEntity - NO DEFAULT VALUES + * FaceModelEntity - MULTI-CENTROID support for temporal tracking */ @Entity( tableName = "face_models", @@ -91,13 +129,13 @@ data class PersonEntity( data class FaceModelEntity( @PrimaryKey @ColumnInfo(name = "id") - val id: String, // ← No default + val id: String, @ColumnInfo(name = "personId") val personId: String, - @ColumnInfo(name = "embedding") - val embedding: String, + @ColumnInfo(name = "centroidsJson") + val centroidsJson: String, // NEW: List as JSON @ColumnInfo(name = "trainingImageCount") val trainingImageCount: Int, @@ -106,10 +144,10 @@ data class FaceModelEntity( val averageConfidence: Float, @ColumnInfo(name = "createdAt") - val createdAt: Long, // ← No default + val createdAt: Long, @ColumnInfo(name = "updatedAt") - val updatedAt: Long, // ← No default + val updatedAt: Long, @ColumnInfo(name = "lastUsed") val lastUsed: Long?, @@ -118,17 +156,42 @@ data class FaceModelEntity( val isActive: Boolean ) { companion object { + /** + * Backwards compatible create() method + * Used by existing FaceRecognitionRepository code + */ fun create( personId: String, embeddingArray: FloatArray, trainingImageCount: Int, averageConfidence: Float + ): FaceModelEntity { + return createFromEmbedding(personId, embeddingArray, trainingImageCount, averageConfidence) + } + + /** + * Create from single embedding (backwards compatible) + */ + fun createFromEmbedding( + personId: String, + embeddingArray: FloatArray, + trainingImageCount: Int, + averageConfidence: Float ): FaceModelEntity { val now = System.currentTimeMillis() + val centroid = TemporalCentroid( + embedding = embeddingArray.toList(), + effectiveTimestamp = now, + ageAtCapture = null, + photoCount = trainingImageCount, + timeRangeMonths = 12, + avgConfidence = averageConfidence + ) + return FaceModelEntity( id = UUID.randomUUID().toString(), personId = personId, - embedding = embeddingArray.joinToString(","), + centroidsJson = serializeCentroids(listOf(centroid)), trainingImageCount = trainingImageCount, averageConfidence = averageConfidence, createdAt = now, @@ -137,15 +200,106 @@ data class FaceModelEntity( isActive = true ) } + + /** + * Create from multiple centroids (temporal tracking) + */ + fun createFromCentroids( + personId: String, + centroids: List, + trainingImageCount: Int, + averageConfidence: Float + ): FaceModelEntity { + val now = System.currentTimeMillis() + return FaceModelEntity( + id = UUID.randomUUID().toString(), + personId = personId, + centroidsJson = serializeCentroids(centroids), + trainingImageCount = trainingImageCount, + averageConfidence = averageConfidence, + createdAt = now, + updatedAt = now, + lastUsed = null, + isActive = true + ) + } + + /** + * Serialize list of centroids to JSON + */ + private fun serializeCentroids(centroids: List): String { + val jsonArray = JSONArray() + centroids.forEach { centroid -> + val jsonObj = JSONObject() + jsonObj.put("embedding", JSONArray(centroid.embedding)) + jsonObj.put("effectiveTimestamp", centroid.effectiveTimestamp) + jsonObj.put("ageAtCapture", centroid.ageAtCapture) + jsonObj.put("photoCount", centroid.photoCount) + jsonObj.put("timeRangeMonths", centroid.timeRangeMonths) + jsonObj.put("avgConfidence", centroid.avgConfidence) + jsonArray.put(jsonObj) + } + return jsonArray.toString() + } + + /** + * Deserialize JSON to list of centroids + */ + private fun deserializeCentroids(json: String): List { + val jsonArray = JSONArray(json) + return (0 until jsonArray.length()).map { i -> + val jsonObj = jsonArray.getJSONObject(i) + val embeddingArray = jsonObj.getJSONArray("embedding") + val embedding = (0 until embeddingArray.length()).map { j -> + embeddingArray.getDouble(j).toFloat() + } + TemporalCentroid( + embedding = embedding, + effectiveTimestamp = jsonObj.getLong("effectiveTimestamp"), + ageAtCapture = if (jsonObj.isNull("ageAtCapture")) null else jsonObj.getDouble("ageAtCapture").toFloat(), + photoCount = jsonObj.getInt("photoCount"), + timeRangeMonths = jsonObj.getInt("timeRangeMonths"), + avgConfidence = jsonObj.getDouble("avgConfidence").toFloat() + ) + } + } } + fun getCentroids(): List { + return try { + FaceModelEntity.deserializeCentroids(centroidsJson) + } catch (e: Exception) { + emptyList() + } + } + + // Backwards compatibility: get first centroid as single embedding fun getEmbeddingArray(): FloatArray { - return embedding.split(",").map { it.toFloat() }.toFloatArray() + val centroids = getCentroids() + return if (centroids.isNotEmpty()) { + centroids.first().getEmbeddingArray() + } else { + FloatArray(192) // Empty embedding + } } } /** - * PhotoFaceTagEntity - NO DEFAULT VALUES + * TemporalCentroid - Represents a face appearance at a specific time period + */ +data class TemporalCentroid( + val embedding: List, // 192D vector + val effectiveTimestamp: Long, // Center of time window + val ageAtCapture: Float?, // Age in years (for children) + val photoCount: Int, // Number of photos in this cluster + val timeRangeMonths: Int, // Width of time window (e.g., 6 months) + val avgConfidence: Float // Quality indicator +) { + fun getEmbeddingArray(): FloatArray = embedding.toFloatArray() +} + +/** + * PhotoFaceTagEntity - Unchanged */ @Entity( tableName = "photo_face_tags", @@ -172,7 +326,7 @@ data class FaceModelEntity( data class PhotoFaceTagEntity( @PrimaryKey @ColumnInfo(name = "id") - val id: String, // ← No default + val id: String, @ColumnInfo(name = "imageId") val imageId: String, @@ -190,7 +344,7 @@ data class PhotoFaceTagEntity( val embedding: String, @ColumnInfo(name = "detectedAt") - val detectedAt: Long, // ← No default + val detectedAt: Long, @ColumnInfo(name = "verifiedByUser") val verifiedByUser: Boolean, @@ -228,4 +382,74 @@ data class PhotoFaceTagEntity( fun getEmbeddingArray(): FloatArray { return embedding.split(",").map { it.toFloat() }.toFloatArray() } +} + +/** + * PersonAgeTagEntity - NEW: Searchable age tags + */ +@Entity( + tableName = "person_age_tags", + foreignKeys = [ + ForeignKey( + entity = PersonEntity::class, + parentColumns = ["id"], + childColumns = ["personId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ImageEntity::class, + parentColumns = ["imageId"], + childColumns = ["imageId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["personId"]), + Index(value = ["imageId"]), + Index(value = ["ageAtCapture"]), + Index(value = ["tagValue"]) + ] +) +data class PersonAgeTagEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: String, + + @ColumnInfo(name = "personId") + val personId: String, + + @ColumnInfo(name = "imageId") + val imageId: String, + + @ColumnInfo(name = "ageAtCapture") + val ageAtCapture: Int, + + @ColumnInfo(name = "tagValue") + val tagValue: String, // e.g., "emma_age3" + + @ColumnInfo(name = "confidence") + val confidence: Float, + + @ColumnInfo(name = "createdAt") + val createdAt: Long +) { + companion object { + fun create( + personId: String, + personName: String, + imageId: String, + ageAtCapture: Int, + confidence: Float + ): PersonAgeTagEntity { + return PersonAgeTagEntity( + id = UUID.randomUUID().toString(), + personId = personId, + imageId = imageId, + ageAtCapture = ageAtCapture, + tagValue = "${personName.lowercase().replace(" ", "_")}_age$ageAtCapture", + confidence = confidence, + createdAt = System.currentTimeMillis() + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt b/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt index c7837e0..e789a30 100644 --- a/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt +++ b/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt @@ -3,6 +3,7 @@ package com.placeholder.sherpai2.di import android.content.Context import androidx.room.Room import com.placeholder.sherpai2.data.local.AppDatabase +import com.placeholder.sherpai2.data.local.MIGRATION_7_8 import com.placeholder.sherpai2.data.local.dao.* import dagger.Module import dagger.Provides @@ -14,9 +15,9 @@ import javax.inject.Singleton /** * DatabaseModule - Provides database and ALL DAOs * - * DEVELOPMENT CONFIGURATION: - * - fallbackToDestructiveMigration enabled - * - No migrations required + * PHASE 2 UPDATES: + * - Added PersonAgeTagDao + * - Added migration v7→v8 (commented out for development) */ @Module @InstallIn(SingletonComponent::class) @@ -34,7 +35,12 @@ object DatabaseModule { AppDatabase::class.java, "sherpai.db" ) + // DEVELOPMENT MODE: Destructive migration (fresh install on schema change) .fallbackToDestructiveMigration() + + // PRODUCTION MODE: Uncomment this and remove fallbackToDestructiveMigration() + // .addMigrations(MIGRATION_7_8) + .build() // ===== CORE DAOs ===== @@ -77,8 +83,13 @@ object DatabaseModule { fun providePhotoFaceTagDao(db: AppDatabase): PhotoFaceTagDao = db.photoFaceTagDao() + @Provides + fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao = // NEW + db.personAgeTagDao() + // ===== COLLECTIONS DAOs ===== + @Provides fun provideCollectionDao(db: AppDatabase): CollectionDao = db.collectionDao() -} +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/clustering/Faceclusteringservice.kt b/app/src/main/java/com/placeholder/sherpai2/domain/clustering/Faceclusteringservice.kt new file mode 100644 index 0000000..b686c49 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/clustering/Faceclusteringservice.kt @@ -0,0 +1,465 @@ +package com.placeholder.sherpai2.domain.clustering + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions +import com.placeholder.sherpai2.data.local.dao.ImageDao +import com.placeholder.sherpai2.data.local.entity.ImageEntity +import com.placeholder.sherpai2.ml.FaceNetModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.sqrt + +/** + * FaceClusteringService - Auto-discover people in photo library + * + * STRATEGY: + * 1. Load all images with faces (from cache) + * 2. Detect faces and generate embeddings (parallel) + * 3. DBSCAN clustering on embeddings + * 4. Co-occurrence analysis (faces in same photo) + * 5. Return high-quality clusters (10-100 people typical) + * + * PERFORMANCE: + * - Uses face detection cache (only ~30% of photos) + * - Parallel processing (12 concurrent) + * - Smart sampling (don't need ALL faces for clustering) + * - Result: ~2-5 minutes for 10,000 photo library + */ +@Singleton +class FaceClusteringService @Inject constructor( + @ApplicationContext private val context: Context, + private val imageDao: ImageDao +) { + + private val semaphore = Semaphore(12) + + /** + * Main clustering entry point + * + * @param maxFacesToCluster Limit for performance (default 2000) + * @param onProgress Progress callback (current, total, message) + */ + suspend fun discoverPeople( + maxFacesToCluster: Int = 2000, + onProgress: (Int, Int, String) -> Unit = { _, _, _ -> } + ): ClusteringResult = withContext(Dispatchers.Default) { + + onProgress(0, 100, "Loading images with faces...") + + // Step 1: Get images with faces (cached, fast!) + val imagesWithFaces = imageDao.getImagesWithFaces() + + if (imagesWithFaces.isEmpty()) { + // Check if face cache is populated at all + val totalImages = withContext(Dispatchers.IO) { + imageDao.getImageCount() + } + + if (totalImages == 0) { + return@withContext ClusteringResult( + clusters = emptyList(), + totalFacesAnalyzed = 0, + processingTimeMs = 0, + errorMessage = "No photos in library. Please wait for photo ingestion to complete." + ) + } + + // Images exist but no face cache - need to run PopulateFaceDetectionCacheUseCase first + return@withContext ClusteringResult( + clusters = emptyList(), + totalFacesAnalyzed = 0, + processingTimeMs = 0, + errorMessage = "Face detection cache not ready. Please wait for initial face scan to complete (check MainActivity progress bar)." + ) + } + + onProgress(10, 100, "Analyzing ${imagesWithFaces.size} photos...") + + val startTime = System.currentTimeMillis() + + // Step 2: Detect faces and generate embeddings (parallel) + val allFaces = detectFacesInImages( + images = imagesWithFaces.take(1000), // Smart limit: don't need all photos + onProgress = { current, total -> + onProgress(10 + (current * 40 / total), 100, "Detecting faces... $current/$total") + } + ) + + if (allFaces.isEmpty()) { + return@withContext ClusteringResult( + clusters = emptyList(), + totalFacesAnalyzed = 0, + processingTimeMs = System.currentTimeMillis() - startTime + ) + } + + onProgress(50, 100, "Clustering ${allFaces.size} faces...") + + // Step 3: DBSCAN clustering on embeddings + val rawClusters = performDBSCAN( + faces = allFaces.take(maxFacesToCluster), + epsilon = 0.30f, // BALANCED: Not too strict, not too loose + minPoints = 5 // Minimum 5 photos to form a cluster + ) + + onProgress(70, 100, "Analyzing relationships...") + + // Step 4: Build co-occurrence graph + val coOccurrenceGraph = buildCoOccurrenceGraph(rawClusters) + + onProgress(80, 100, "Selecting representative faces...") + + // Step 5: Select representative faces for each cluster + val clusters = rawClusters.map { cluster -> + FaceCluster( + clusterId = cluster.clusterId, + faces = cluster.faces, + representativeFaces = selectRepresentativeFaces(cluster.faces, count = 6), + photoCount = cluster.faces.map { it.imageId }.distinct().size, + averageConfidence = cluster.faces.map { it.confidence }.average().toFloat(), + estimatedAge = estimateAge(cluster.faces), + potentialSiblings = findPotentialSiblings(cluster, rawClusters, coOccurrenceGraph) + ) + }.sortedByDescending { it.photoCount } // Most frequent first + + onProgress(100, 100, "Found ${clusters.size} people!") + + ClusteringResult( + clusters = clusters, + totalFacesAnalyzed = allFaces.size, + processingTimeMs = System.currentTimeMillis() - startTime + ) + } + + /** + * Detect faces in images and generate embeddings (parallel) + */ + private suspend fun detectFacesInImages( + images: List, + onProgress: (Int, Int) -> Unit + ): List = coroutineScope { + + val detector = com.google.mlkit.vision.face.FaceDetection.getClient( + com.google.mlkit.vision.face.FaceDetectorOptions.Builder() + .setPerformanceMode(com.google.mlkit.vision.face.FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) + .setMinFaceSize(0.15f) + .build() + ) + + val faceNetModel = FaceNetModel(context) + val allFaces = mutableListOf() + val processedCount = java.util.concurrent.atomic.AtomicInteger(0) + + try { + val jobs = images.map { image -> + async { + semaphore.acquire() + try { + val faces = detectFacesInImage(image, detector, faceNetModel) + val current = processedCount.incrementAndGet() + if (current % 10 == 0) { + onProgress(current, images.size) + } + faces + } finally { + semaphore.release() + } + } + } + + jobs.awaitAll().flatten().also { + allFaces.addAll(it) + } + + } finally { + detector.close() + faceNetModel.close() + } + + allFaces + } + + private suspend fun detectFacesInImage( + image: ImageEntity, + detector: com.google.mlkit.vision.face.FaceDetector, + faceNetModel: FaceNetModel + ): List = withContext(Dispatchers.IO) { + + try { + val uri = Uri.parse(image.imageUri) + val bitmap = loadBitmapDownsampled(uri, 512) ?: return@withContext emptyList() + + val mlImage = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0) + val faces = com.google.android.gms.tasks.Tasks.await(detector.process(mlImage)) + + val result = faces.mapNotNull { face -> + try { + val faceBitmap = Bitmap.createBitmap( + bitmap, + face.boundingBox.left.coerceIn(0, bitmap.width - 1), + face.boundingBox.top.coerceIn(0, bitmap.height - 1), + face.boundingBox.width().coerceAtMost(bitmap.width - face.boundingBox.left), + face.boundingBox.height().coerceAtMost(bitmap.height - face.boundingBox.top) + ) + + val embedding = faceNetModel.generateEmbedding(faceBitmap) + faceBitmap.recycle() + + DetectedFaceWithEmbedding( + imageId = image.imageId, + imageUri = image.imageUri, + capturedAt = image.capturedAt, + embedding = embedding, + boundingBox = face.boundingBox, + confidence = 1.0f // Placeholder + ) + } catch (e: Exception) { + null + } + } + + bitmap.recycle() + result + + } catch (e: Exception) { + emptyList() + } + } + + /** + * DBSCAN clustering algorithm + */ + private fun performDBSCAN( + faces: List, + epsilon: Float, + minPoints: Int + ): List { + + val visited = mutableSetOf() + val clusters = mutableListOf() + var clusterId = 0 + + for (i in faces.indices) { + if (i in visited) continue + + val neighbors = findNeighbors(i, faces, epsilon) + + if (neighbors.size < minPoints) { + visited.add(i) + continue // Noise point + } + + // Start new cluster + val cluster = mutableListOf() + val queue = ArrayDeque(neighbors) + visited.add(i) + cluster.add(faces[i]) + + while (queue.isNotEmpty()) { + val pointIdx = queue.removeFirst() + if (pointIdx in visited) continue + + visited.add(pointIdx) + cluster.add(faces[pointIdx]) + + val pointNeighbors = findNeighbors(pointIdx, faces, epsilon) + if (pointNeighbors.size >= minPoints) { + queue.addAll(pointNeighbors.filter { it !in visited }) + } + } + + if (cluster.size >= minPoints) { + clusters.add(RawCluster(clusterId++, cluster)) + } + } + + return clusters + } + + private fun findNeighbors( + pointIdx: Int, + faces: List, + epsilon: Float + ): List { + val point = faces[pointIdx] + return faces.indices.filter { i -> + i != pointIdx && cosineSimilarity(point.embedding, faces[i].embedding) > (1 - epsilon) + } + } + + private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float { + var dotProduct = 0f + var normA = 0f + var normB = 0f + + for (i in a.indices) { + dotProduct += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + return dotProduct / (sqrt(normA) * sqrt(normB)) + } + + /** + * Build co-occurrence graph (faces appearing in same photos) + */ + private fun buildCoOccurrenceGraph(clusters: List): Map> { + val graph = mutableMapOf>() + + for (i in clusters.indices) { + graph[i] = mutableMapOf() + val imageIds = clusters[i].faces.map { it.imageId }.toSet() + + for (j in clusters.indices) { + if (i == j) continue + + val sharedImages = clusters[j].faces.count { it.imageId in imageIds } + if (sharedImages > 0) { + graph[i]!![j] = sharedImages + } + } + } + + return graph + } + + private fun findPotentialSiblings( + cluster: RawCluster, + allClusters: List, + coOccurrenceGraph: Map> + ): List { + val clusterIdx = allClusters.indexOf(cluster) + if (clusterIdx == -1) return emptyList() + + val siblings = coOccurrenceGraph[clusterIdx] + ?.filter { (_, count) -> count >= 5 } // At least 5 shared photos + ?.keys + ?.toList() + ?: emptyList() + + return siblings + } + + /** + * Select diverse representative faces for UI display + */ + private fun selectRepresentativeFaces( + faces: List, + count: Int + ): List { + if (faces.size <= count) return faces + + // Time-based sampling: spread across different dates + val sortedByTime = faces.sortedBy { it.capturedAt } + val step = faces.size / count + + return (0 until count).map { i -> + sortedByTime[i * step] + } + } + + /** + * Estimate if cluster represents a child (based on photo timestamps) + */ + private fun estimateAge(faces: List): AgeEstimate { + val timestamps = faces.map { it.capturedAt }.sorted() + val span = timestamps.last() - timestamps.first() + val spanYears = span / (365.25 * 24 * 60 * 60 * 1000) + + // If face appearance changes over 3+ years, likely a child + return if (spanYears > 3.0) { + AgeEstimate.CHILD + } else { + AgeEstimate.UNKNOWN + } + } + + private fun loadBitmapDownsampled(uri: Uri, maxDim: Int): Bitmap? { + return try { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, opts) + } + + var sample = 1 + while (opts.outWidth / sample > maxDim || opts.outHeight / sample > maxDim) { + sample *= 2 + } + + val finalOpts = BitmapFactory.Options().apply { + inSampleSize = sample + inPreferredConfig = Bitmap.Config.RGB_565 + } + + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, finalOpts) + } + } catch (e: Exception) { + null + } + } +} + +// ================== +// DATA CLASSES +// ================== + +data class DetectedFaceWithEmbedding( + val imageId: String, + val imageUri: String, + val capturedAt: Long, + val embedding: FloatArray, + val boundingBox: android.graphics.Rect, + val confidence: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DetectedFaceWithEmbedding + return imageId == other.imageId + } + + override fun hashCode(): Int = imageId.hashCode() +} + +data class RawCluster( + val clusterId: Int, + val faces: List +) + +data class FaceCluster( + val clusterId: Int, + val faces: List, + val representativeFaces: List, + val photoCount: Int, + val averageConfidence: Float, + val estimatedAge: AgeEstimate, + val potentialSiblings: List +) + +data class ClusteringResult( + val clusters: List, + val totalFacesAnalyzed: Int, + val processingTimeMs: Long, + val errorMessage: String? = null +) + +enum class AgeEstimate { + CHILD, // Appearance changes significantly over time + ADULT, // Stable appearance + UNKNOWN // Not enough data +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/training/Clustertrainingservice.kt b/app/src/main/java/com/placeholder/sherpai2/domain/training/Clustertrainingservice.kt new file mode 100644 index 0000000..475074f --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/domain/training/Clustertrainingservice.kt @@ -0,0 +1,234 @@ +package com.placeholder.sherpai2.domain.training + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import com.placeholder.sherpai2.data.local.dao.FaceModelDao +import com.placeholder.sherpai2.data.local.dao.PersonDao +import com.placeholder.sherpai2.data.local.entity.FaceModelEntity +import com.placeholder.sherpai2.data.local.entity.PersonEntity +import com.placeholder.sherpai2.data.local.entity.TemporalCentroid +import com.placeholder.sherpai2.domain.clustering.FaceCluster +import com.placeholder.sherpai2.ml.FaceNetModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +/** + * ClusterTrainingService - Train multi-centroid face models from clusters + * + * STRATEGY: + * 1. For children: Create multiple temporal centroids (one per age period) + * 2. For adults: Create single centroid (stable appearance) + * 3. Use K-Means clustering on timestamps to find age groups + * 4. Calculate centroid for each time period + */ +@Singleton +class ClusterTrainingService @Inject constructor( + @ApplicationContext private val context: Context, + private val personDao: PersonDao, + private val faceModelDao: FaceModelDao +) { + + private val faceNetModel by lazy { FaceNetModel(context) } + + /** + * Train a person from an auto-discovered cluster + * + * @return PersonId on success + */ + suspend fun trainFromCluster( + cluster: FaceCluster, + name: String, + dateOfBirth: Long?, + isChild: Boolean, + siblingClusterIds: List, + onProgress: (Int, Int, String) -> Unit = { _, _, _ -> } + ): String = withContext(Dispatchers.Default) { + + onProgress(0, 100, "Creating person...") + + // Step 1: Create PersonEntity + val person = PersonEntity.create( + name = name, + dateOfBirth = dateOfBirth, + isChild = isChild, + siblingIds = emptyList(), // Will update after siblings are created + relationship = if (isChild) "Child" else null + ) + + withContext(Dispatchers.IO) { + personDao.insert(person) + } + + onProgress(20, 100, "Analyzing face variations...") + + // Step 2: Generate embeddings for all faces in cluster + val facesWithEmbeddings = cluster.faces.mapNotNull { face -> + try { + val bitmap = context.contentResolver.openInputStream(Uri.parse(face.imageUri))?.use { + BitmapFactory.decodeStream(it) + } ?: return@mapNotNull null + + // Generate embedding + val embedding = faceNetModel.generateEmbedding(bitmap) + bitmap.recycle() + + Triple(face.imageUri, face.capturedAt, embedding) + } catch (e: Exception) { + null + } + } + + if (facesWithEmbeddings.isEmpty()) { + throw Exception("Failed to process any faces from cluster") + } + + onProgress(50, 100, "Creating face model...") + + // Step 3: Create centroids based on whether person is a child + val centroids = if (isChild && dateOfBirth != null) { + createTemporalCentroidsForChild( + facesWithEmbeddings = facesWithEmbeddings, + dateOfBirth = dateOfBirth + ) + } else { + createSingleCentroid(facesWithEmbeddings) + } + + onProgress(80, 100, "Saving model...") + + // Step 4: Calculate average confidence + val avgConfidence = centroids.map { it.avgConfidence }.average().toFloat() + + // Step 5: Create FaceModelEntity + val faceModel = FaceModelEntity.createFromCentroids( + personId = person.id, + centroids = centroids, + trainingImageCount = cluster.faces.size, + averageConfidence = avgConfidence + ) + + withContext(Dispatchers.IO) { + faceModelDao.insertFaceModel(faceModel) + } + + onProgress(100, 100, "Complete!") + + person.id + } + + /** + * Create temporal centroids for a child + * Groups faces by age and creates one centroid per age period + */ + private fun createTemporalCentroidsForChild( + facesWithEmbeddings: List>, + dateOfBirth: Long + ): List { + + // Group faces by age (in years) + val facesByAge = facesWithEmbeddings.groupBy { (_, capturedAt, _) -> + val ageMs = capturedAt - dateOfBirth + val ageYears = (ageMs / (365.25 * 24 * 60 * 60 * 1000)).toInt() + ageYears.coerceIn(0, 18) // Cap at 18 years + } + + // Create one centroid per age group + return facesByAge.map { (age, faces) -> + val embeddings = faces.map { it.third } + val avgEmbedding = averageEmbeddings(embeddings) + val avgTimestamp = faces.map { it.second }.average().toLong() + + // Calculate confidence (how similar faces are to each other) + val confidences = embeddings.map { emb -> + cosineSimilarity(avgEmbedding, emb) + } + val avgConfidence = confidences.average().toFloat() + + TemporalCentroid( + embedding = avgEmbedding.toList(), + effectiveTimestamp = avgTimestamp, + ageAtCapture = age.toFloat(), + photoCount = faces.size, + timeRangeMonths = 12, // 1 year window + avgConfidence = avgConfidence + ) + }.sortedBy { it.ageAtCapture } + } + + /** + * Create single centroid for an adult (stable appearance) + */ + private fun createSingleCentroid( + facesWithEmbeddings: List> + ): List { + + val embeddings = facesWithEmbeddings.map { it.third } + val avgEmbedding = averageEmbeddings(embeddings) + val avgTimestamp = facesWithEmbeddings.map { it.second }.average().toLong() + + val confidences = embeddings.map { emb -> + cosineSimilarity(avgEmbedding, emb) + } + val avgConfidence = confidences.average().toFloat() + + return listOf( + TemporalCentroid( + embedding = avgEmbedding.toList(), + effectiveTimestamp = avgTimestamp, + ageAtCapture = null, + photoCount = facesWithEmbeddings.size, + timeRangeMonths = 24, // 2 year window for adults + avgConfidence = avgConfidence + ) + ) + } + + /** + * Average multiple embeddings into one + */ + private fun averageEmbeddings(embeddings: List): FloatArray { + val size = embeddings.first().size + val avg = FloatArray(size) { 0f } + + embeddings.forEach { embedding -> + for (i in embedding.indices) { + avg[i] += embedding[i] + } + } + + val count = embeddings.size.toFloat() + for (i in avg.indices) { + avg[i] /= count + } + + // Normalize to unit length + val norm = kotlin.math.sqrt(avg.map { it * it }.sum()) + return avg.map { it / norm }.toFloatArray() + } + + /** + * Calculate cosine similarity between two embeddings + */ + private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float { + var dotProduct = 0f + var normA = 0f + var normB = 0f + + for (i in a.indices) { + dotProduct += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + + return dotProduct / (kotlin.math.sqrt(normA) * kotlin.math.sqrt(normB)) + } + + fun cleanup() { + faceNetModel.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/domain/usecase/Populatefacedetectioncacheusecase.kt b/app/src/main/java/com/placeholder/sherpai2/domain/usecase/Populatefacedetectioncacheusecase.kt index b8697f5..5c5effe 100644 --- a/app/src/main/java/com/placeholder/sherpai2/domain/usecase/Populatefacedetectioncacheusecase.kt +++ b/app/src/main/java/com/placeholder/sherpai2/domain/usecase/Populatefacedetectioncacheusecase.kt @@ -176,7 +176,7 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor( faceCount = update.faceCount ) } catch (e: Exception) { - // Skip failed updates + // Skip failed updates //todo } } } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeoplescreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeoplescreen.kt new file mode 100644 index 0000000..7b95229 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeoplescreen.kt @@ -0,0 +1,687 @@ +package com.placeholder.sherpai2.ui.discover + +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.placeholder.sherpai2.domain.clustering.AgeEstimate +import com.placeholder.sherpai2.domain.clustering.FaceCluster +import java.text.SimpleDateFormat +import java.util.* + +/** + * DiscoverPeopleScreen - Beautiful auto-clustering UI + * + * FLOW: + * 1. Hero CTA: "Discover People in Your Photos" + * 2. Auto-clustering progress (2-5 min) + * 3. Grid of discovered people + * 4. Tap cluster → Name person + metadata + * 5. Background deep scan starts + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscoverPeopleScreen( + viewModel: DiscoverPeopleViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // NO SCAFFOLD - MainScreen already has TopAppBar + Box(modifier = Modifier.fillMaxSize()) { + when (val state = uiState) { + is DiscoverUiState.Idle -> IdleScreen( + onStartDiscovery = { viewModel.startDiscovery() } + ) + + is DiscoverUiState.Clustering -> ClusteringProgressScreen( + progress = state.progress, + total = state.total, + message = state.message + ) + + is DiscoverUiState.NamingReady -> ClusterGridScreen( + result = state.result, + onClusterClick = { cluster -> + viewModel.selectCluster(cluster) + } + ) + + is DiscoverUiState.NamingCluster -> NamingDialog( + cluster = state.selectedCluster, + suggestedSiblings = state.suggestedSiblings, + onConfirm = { name, dob, isChild, siblings -> + viewModel.confirmClusterName( + cluster = state.selectedCluster, + name = name, + dateOfBirth = dob, + isChild = isChild, + selectedSiblings = siblings + ) + }, + onDismiss = { viewModel.cancelNaming() } + ) + + is DiscoverUiState.NoPeopleFound -> EmptyStateScreen( + message = state.message + ) + + is DiscoverUiState.Error -> ErrorScreen( + message = state.message, + onRetry = { viewModel.startDiscovery() } + ) + } + } +} + +/** + * Idle state - Hero CTA to start discovery + */ +@Composable +fun IdleScreen( + onStartDiscovery: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = "Discover People", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = "Let AI automatically find and group faces in your photos. " + + "You'll name them, and we'll tag all their photos.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(32.dp)) + + Button( + onClick = onStartDiscovery, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Start Discovery", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + Spacer(Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + InfoRow(Icons.Default.Speed, "Fast: Analyzes ~1000 photos in 2-5 minutes") + InfoRow(Icons.Default.Security, "Private: Everything stays on your device") + InfoRow(Icons.Default.AutoAwesome, "Smart: Groups faces automatically") + } + } + } +} + +@Composable +fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +/** + * Clustering progress screen + */ +@Composable +fun ClusteringProgressScreen( + progress: Int, + total: Int, + message: String +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(80.dp), + strokeWidth = 6.dp + ) + + Spacer(Modifier.height(32.dp)) + + Text( + text = "Discovering People...", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = { if (total > 0) progress.toFloat() / total.toFloat() else 0f }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = "This will take 2-5 minutes. You can leave and come back later.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Grid of discovered clusters + */ +@Composable +fun ClusterGridScreen( + result: com.placeholder.sherpai2.domain.clustering.ClusteringResult, + onClusterClick: (FaceCluster) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text( + text = "Found ${result.clusters.size} People", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Tap to name each person. We'll then tag all their photos.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(16.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(result.clusters) { cluster -> + ClusterCard( + cluster = cluster, + onClick = { onClusterClick(cluster) } + ) + } + } + } +} + +/** + * Single cluster card + */ +@Composable +fun ClusterCard( + cluster: FaceCluster, + onClick: () -> Unit +) { + val context = LocalContext.current + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column { + // Face grid (2x3) + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.height(180.dp), + userScrollEnabled = false + ) { + items(cluster.representativeFaces.take(6)) { face -> + val bitmap = remember(face.imageUri) { + try { + context.contentResolver.openInputStream(Uri.parse(face.imageUri))?.use { + BitmapFactory.decodeStream(it) + } + } catch (e: Exception) { + null + } + } + + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // Info + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${cluster.photoCount} photos", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (cluster.estimatedAge == AgeEstimate.CHILD) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "Child", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + if (cluster.potentialSiblings.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + text = "Appears with ${cluster.potentialSiblings.size} other person(s)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * Naming dialog + */ +@Composable +fun NamingDialog( + cluster: FaceCluster, + suggestedSiblings: List, + onConfirm: (String, Long?, Boolean, List) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf("") } + var isChild by remember { mutableStateOf(cluster.estimatedAge == AgeEstimate.CHILD) } + var dateOfBirth by remember { mutableStateOf(null) } + var selectedSiblings by remember { mutableStateOf>(emptySet()) } + var showDatePicker by remember { mutableStateOf(false) } + val context = LocalContext.current + + // Date picker dialog + if (showDatePicker) { + val calendar = java.util.Calendar.getInstance() + if (dateOfBirth != null) { + calendar.timeInMillis = dateOfBirth!! + } + + val datePickerDialog = android.app.DatePickerDialog( + context, + { _, year, month, dayOfMonth -> + val cal = java.util.Calendar.getInstance() + cal.set(year, month, dayOfMonth) + dateOfBirth = cal.timeInMillis + showDatePicker = false + }, + calendar.get(java.util.Calendar.YEAR), + calendar.get(java.util.Calendar.MONTH), + calendar.get(java.util.Calendar.DAY_OF_MONTH) + ) + + datePickerDialog.setOnDismissListener { + showDatePicker = false + } + + DisposableEffect(Unit) { + datePickerDialog.show() + onDispose { + datePickerDialog.dismiss() + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Name This Person") + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // FACE PREVIEW - Show 6 representative faces + Text( + text = "${cluster.photoCount} photos found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.height(180.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(cluster.representativeFaces.take(6)) { face -> + val bitmap = remember(face.imageUri) { + try { + context.contentResolver.openInputStream(Uri.parse(face.imageUri))?.use { + BitmapFactory.decodeStream(it) + } + } catch (e: Exception) { + null + } + } + + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + HorizontalDivider() + + // Name input + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Is child toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("This person is a child") + Switch( + checked = isChild, + onCheckedChange = { isChild = it } + ) + } + + // Date of birth (if child) + if (isChild) { + OutlinedButton( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.CalendarToday, null) + Spacer(Modifier.width(8.dp)) + Text( + if (dateOfBirth != null) { + SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + .format(Date(dateOfBirth!!)) + } else { + "Set Date of Birth" + } + ) + } + } + + // Suggested siblings + if (suggestedSiblings.isNotEmpty()) { + Text( + "Appears with these people (select siblings):", + style = MaterialTheme.typography.labelMedium + ) + + suggestedSiblings.forEach { sibling -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = sibling.clusterId in selectedSiblings, + onCheckedChange = { checked -> + selectedSiblings = if (checked) { + selectedSiblings + sibling.clusterId + } else { + selectedSiblings - sibling.clusterId + } + } + ) + Text("Person ${sibling.clusterId + 1} (${sibling.photoCount} photos)") + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm( + name, + dateOfBirth, + isChild, + selectedSiblings.toList() + ) + }, + enabled = name.isNotBlank() + ) { + Text("Save & Train") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) + + // TODO: Add DatePickerDialog when showDatePicker is true +} + +/** + * Empty state screen + */ +@Composable +fun EmptyStateScreen(message: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } +} + +/** + * Error screen + */ +@Composable +fun ErrorScreen( + message: String, + onRetry: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.error + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = "Oops!", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(Modifier.height(24.dp)) + + Button(onClick = onRetry) { + Text("Try Again") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeopleviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeopleviewmodel.kt new file mode 100644 index 0000000..af490ad --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeopleviewmodel.kt @@ -0,0 +1,222 @@ +package com.placeholder.sherpai2.ui.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.domain.clustering.ClusteringResult +import com.placeholder.sherpai2.domain.clustering.FaceCluster +import com.placeholder.sherpai2.domain.clustering.FaceClusteringService +import com.placeholder.sherpai2.domain.training.ClusterTrainingService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * DiscoverPeopleViewModel - Manages auto-clustering and naming flow + * + * PHASE 2: Now includes multi-centroid training from clusters + * + * STATE FLOW: + * 1. Idle → User taps "Discover People" + * 2. Clustering → Auto-analyzing faces (2-5 min) + * 3. NamingReady → Shows clusters, user names them + * 4. Training → Creating multi-centroid face model + * 5. Complete → Ready to scan library + */ +@HiltViewModel +class DiscoverPeopleViewModel @Inject constructor( + private val clusteringService: FaceClusteringService, + private val trainingService: ClusterTrainingService +) : ViewModel() { + + private val _uiState = MutableStateFlow(DiscoverUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + // Track which clusters have been named + private val namedClusterIds = mutableSetOf() + + /** + * Start auto-clustering process + */ + fun startDiscovery() { + viewModelScope.launch { + try { + // Clear named clusters for new discovery + namedClusterIds.clear() + + _uiState.value = DiscoverUiState.Clustering(0, 100, "Starting...") + + val result = clusteringService.discoverPeople( + onProgress = { current, total, message -> + _uiState.value = DiscoverUiState.Clustering(current, total, message) + } + ) + + // Check for errors + if (result.errorMessage != null) { + _uiState.value = DiscoverUiState.Error(result.errorMessage) + return@launch + } + + if (result.clusters.isEmpty()) { + _uiState.value = DiscoverUiState.NoPeopleFound( + "No faces found in your library. Make sure face detection cache is populated." + ) + } else { + _uiState.value = DiscoverUiState.NamingReady(result) + } + + } catch (e: Exception) { + _uiState.value = DiscoverUiState.Error( + e.message ?: "Failed to discover people" + ) + } + } + } + + /** + * User selected a cluster to name + */ + fun selectCluster(cluster: FaceCluster) { + val currentState = _uiState.value + if (currentState is DiscoverUiState.NamingReady) { + _uiState.value = DiscoverUiState.NamingCluster( + result = currentState.result, + selectedCluster = cluster, + suggestedSiblings = currentState.result.clusters.filter { + it.clusterId in cluster.potentialSiblings + } + ) + } + } + + /** + * User confirmed name and metadata for a cluster + * + * CREATES: + * 1. PersonEntity with all metadata (name, DOB, siblings) + * 2. Multi-centroid FaceModelEntity (temporal tracking for children) + * 3. Removes cluster from display + */ + fun confirmClusterName( + cluster: FaceCluster, + name: String, + dateOfBirth: Long?, + isChild: Boolean, + selectedSiblings: List + ) { + viewModelScope.launch { + try { + val currentState = _uiState.value + if (currentState !is DiscoverUiState.NamingCluster) return@launch + + // Train person from cluster + val personId = trainingService.trainFromCluster( + cluster = cluster, + name = name, + dateOfBirth = dateOfBirth, + isChild = isChild, + siblingClusterIds = selectedSiblings, + onProgress = { current, total, message -> + _uiState.value = DiscoverUiState.Clustering(current, total, message) + } + ) + + // Mark cluster as named + namedClusterIds.add(cluster.clusterId) + + // Filter out named clusters + val remainingClusters = currentState.result.clusters + .filter { it.clusterId !in namedClusterIds } + + if (remainingClusters.isEmpty()) { + // All clusters named! Show success + _uiState.value = DiscoverUiState.NoPeopleFound( + "All people have been named! 🎉\n\nGo to 'People' to see your trained models." + ) + } else { + // Return to naming screen with remaining clusters + _uiState.value = DiscoverUiState.NamingReady( + result = currentState.result.copy(clusters = remainingClusters) + ) + } + + } catch (e: Exception) { + _uiState.value = DiscoverUiState.Error( + e.message ?: "Failed to create person: ${e.message}" + ) + } + } + } + + /** + * Cancel naming and go back to cluster list + */ + fun cancelNaming() { + val currentState = _uiState.value + if (currentState is DiscoverUiState.NamingCluster) { + _uiState.value = DiscoverUiState.NamingReady( + result = currentState.result + ) + } + } + + /** + * Reset to idle state + */ + fun reset() { + _uiState.value = DiscoverUiState.Idle + } +} + +/** + * UI States for Discover People flow + */ +sealed class DiscoverUiState { + + /** + * Initial state - user hasn't started discovery + */ + object Idle : DiscoverUiState() + + /** + * Auto-clustering in progress + */ + data class Clustering( + val progress: Int, + val total: Int, + val message: String + ) : DiscoverUiState() + + /** + * Clustering complete, ready for user to name people + */ + data class NamingReady( + val result: ClusteringResult + ) : DiscoverUiState() + + /** + * User is naming a specific cluster + */ + data class NamingCluster( + val result: ClusteringResult, + val selectedCluster: FaceCluster, + val suggestedSiblings: List + ) : DiscoverUiState() + + /** + * No people found in library + */ + data class NoPeopleFound( + val message: String + ) : DiscoverUiState() + + /** + * Error occurred + */ + data class Error( + val message: String + ) : DiscoverUiState() +} \ No newline at end of file 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 9e3ad00..6af7687 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 @@ -47,31 +47,31 @@ sealed class AppDestinations( description = "Your photo collections" ) - // ImageDetail is not in draw er (internal navigation only) + // ImageDetail is not in drawer (internal navigation only) // ================== // FACE RECOGNITION // ================== + data object Discover : AppDestinations( + route = AppRoutes.DISCOVER, + icon = Icons.Default.AutoAwesome, + label = "Discover", + description = "Find people in your photos" + ) + data object Inventory : AppDestinations( route = AppRoutes.INVENTORY, icon = Icons.Default.Face, - label = "People Models", - description = "Existing Face Detection Models" + label = "People", + description = "Manage recognized people" ) data object Train : AppDestinations( route = AppRoutes.TRAIN, icon = Icons.Default.ModelTraining, - label = "Create Model", - description = "Create a new Person Model" - ) - - data object Models : AppDestinations( - route = AppRoutes.MODELS, - icon = Icons.Default.SmartToy, - label = "Generative", - description = "AI Creation" + label = "Train Model", + description = "Create a new person model" ) // ================== @@ -117,9 +117,9 @@ val photoDestinations = listOf( // Face recognition section val faceRecognitionDestinations = listOf( + AppDestinations.Discover, // ✨ NEW: Auto-cluster discovery AppDestinations.Inventory, - AppDestinations.Train, - AppDestinations.Models + AppDestinations.Train ) // Organization section @@ -145,9 +145,9 @@ fun getDestinationByRoute(route: String?): AppDestinations? { AppRoutes.SEARCH -> AppDestinations.Search AppRoutes.EXPLORE -> AppDestinations.Explore AppRoutes.COLLECTIONS -> AppDestinations.Collections + AppRoutes.DISCOVER -> AppDestinations.Discover AppRoutes.INVENTORY -> AppDestinations.Inventory AppRoutes.TRAIN -> AppDestinations.Train - AppRoutes.MODELS -> AppDestinations.Models AppRoutes.TAGS -> AppDestinations.Tags AppRoutes.UTILITIES -> AppDestinations.UTILITIES AppRoutes.SETTINGS -> AppDestinations.Settings diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt index d1c9e5c..17a58d8 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt @@ -18,6 +18,7 @@ import com.placeholder.sherpai2.ui.album.AlbumViewScreen import com.placeholder.sherpai2.ui.album.AlbumViewModel import com.placeholder.sherpai2.ui.collections.CollectionsScreen import com.placeholder.sherpai2.ui.collections.CollectionsViewModel +import com.placeholder.sherpai2.ui.discover.DiscoverPeopleScreen import com.placeholder.sherpai2.ui.explore.ExploreScreen import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen @@ -32,15 +33,12 @@ import com.placeholder.sherpai2.ui.trainingprep.TrainingPhotoSelectorScreen import com.placeholder.sherpai2.ui.utilities.PhotoUtilitiesScreen import java.net.URLDecoder import java.net.URLEncoder +import com.placeholder.sherpai2.ui.navigation.AppRoutes /** - * AppNavHost - UPDATED with TrainingPhotoSelector integration + * AppNavHost - UPDATED with Discover People screen * - * Changes: - * - Replaced ImageSelectorScreen with TrainingPhotoSelectorScreen - * - Shows ONLY photos with faces (hasFaces=true) - * - Multi-select photo gallery for training - * - Filters 10,000 photos → ~500 with faces for fast selection + * NEW: Replaces placeholder "Models" screen with auto-clustering face discovery */ @Composable fun AppNavHost( @@ -185,6 +183,22 @@ fun AppNavHost( // FACE RECOGNITION SYSTEM // ========================================== + /** + * DISCOVER PEOPLE SCREEN - ✨ NEW! + * + * Auto-clustering face discovery with spoon-feed naming flow: + * 1. Auto-clusters all faces in library (2-5 min) + * 2. Shows beautiful grid of discovered people + * 3. User taps to name each person + * 4. Captures: name, DOB, sibling relationships + * 5. Triggers deep background scan with age tagging + * + * Replaces: Old "Models" placeholder screen + */ + composable(AppRoutes.DISCOVER) { + DiscoverPeopleScreen() + } + /** * PERSON INVENTORY SCREEN */ @@ -197,7 +211,7 @@ fun AppNavHost( } /** - * TRAINING FLOW - UPDATED with TrainingPhotoSelector + * TRAINING FLOW - Manual training (still available) */ composable(AppRoutes.TRAIN) { entry -> val trainViewModel: TrainViewModel = hiltViewModel() @@ -235,15 +249,7 @@ fun AppNavHost( } /** - * TRAINING PHOTO SELECTOR - NEW: Custom gallery with face filtering - * - * Replaces native photo picker with custom selector that: - * - Shows ONLY photos with hasFaces=true - * - Multi-select with visual feedback - * - Face count badges on each photo - * - Enforces minimum 15 photos - * - * Result: User browses ~500 photos instead of 10,000! + * TRAINING PHOTO SELECTOR - Custom gallery with face filtering */ composable(AppRoutes.TRAINING_PHOTO_SELECTOR) { TrainingPhotoSelectorScreen( @@ -261,12 +267,12 @@ fun AppNavHost( } /** - * MODELS SCREEN + * MODELS SCREEN - DEPRECATED, kept for backwards compat */ composable(AppRoutes.MODELS) { DummyScreen( title = "AI Models", - subtitle = "Manage face recognition models" + subtitle = "Use 'Discover' instead" ) } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt index 2a40395..c5cb1a1 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt @@ -17,9 +17,10 @@ object AppRoutes { const val IMAGE_DETAIL = "IMAGE_DETAIL" // Face recognition + const val DISCOVER = "discover" // ✨ NEW: Auto-cluster face discovery const val INVENTORY = "inv" const val TRAIN = "train" - const val MODELS = "models" + const val MODELS = "models" // DEPRECATED - kept for reference only // Organization const val TAGS = "tags" @@ -30,7 +31,7 @@ object AppRoutes { // Internal training flow screens const val IMAGE_SELECTOR = "Image Selection" // DEPRECATED - kept for reference only - const val TRAINING_PHOTO_SELECTOR = "training_photo_selector" // NEW: Face-filtered gallery + const val TRAINING_PHOTO_SELECTOR = "training_photo_selector" // Face-filtered gallery const val CROP_SCREEN = "CROP_SCREEN" const val TRAINING_SCREEN = "TRAINING_SCREEN" const val ScanResultsScreen = "First Scan Results" 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 b1afd38..167ca48 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 @@ -21,7 +21,7 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes /** * SLIMMED DOWN AppDrawer - 280dp width, inline logo, cleaner sections - * NOW WITH: Scrollable support for small phones + Collections item + * UPDATED: Discover People feature with sparkle icon ✨ */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -109,7 +109,7 @@ fun AppDrawerContent( val photoItems = listOf( DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search), DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore), - DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections) // NEW! + DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections) ) photoItems.forEach { item -> @@ -126,9 +126,9 @@ fun AppDrawerContent( DrawerSection(title = "Face Recognition") val faceItems = listOf( + DrawerItem(AppRoutes.DISCOVER, "Discover", Icons.Default.AutoAwesome), // ✨ UPDATED! DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face), - DrawerItem(AppRoutes.TRAIN, "Create Person", Icons.Default.ModelTraining), - DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy) + DrawerItem(AppRoutes.TRAIN, "Train Model", Icons.Default.ModelTraining) ) faceItems.forEach { item -> 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 index 7eab4a1..cf23118 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt @@ -15,7 +15,7 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes import kotlinx.coroutines.launch /** - * Clean main screen - NO duplicate FABs, Collections support + * Clean main screen - NO duplicate FABs, Collections support, Discover People */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -98,7 +98,6 @@ fun MainScreen() { ) } } - // NOTE: Removed TAGS action - TagManagementScreen has its own inline FAB } }, colors = TopAppBarDefaults.topAppBarColors( @@ -109,7 +108,6 @@ fun MainScreen() { ) ) } - // NOTE: NO floatingActionButton here - individual screens manage their own FABs inline ) { paddingValues -> AppNavHost( navController = navController, @@ -126,10 +124,11 @@ private fun getScreenTitle(route: String): String { return when (route) { AppRoutes.SEARCH -> "Search" AppRoutes.EXPLORE -> "Explore" - AppRoutes.COLLECTIONS -> "Collections" // NEW! + AppRoutes.COLLECTIONS -> "Collections" + AppRoutes.DISCOVER -> "Discover People" // ✨ NEW! AppRoutes.INVENTORY -> "People" AppRoutes.TRAIN -> "Train New Person" - AppRoutes.MODELS -> "AI Models" + AppRoutes.MODELS -> "AI Models" // Deprecated, but keep for backwards compat AppRoutes.TAGS -> "Tag Management" AppRoutes.UTILITIES -> "Photo Util." AppRoutes.SETTINGS -> "Settings" @@ -144,7 +143,8 @@ private fun getScreenSubtitle(route: String): String? { return when (route) { AppRoutes.SEARCH -> "Find photos by tags, people, or date" AppRoutes.EXPLORE -> "Browse your collection" - AppRoutes.COLLECTIONS -> "Your photo collections" // NEW! + AppRoutes.COLLECTIONS -> "Your photo collections" + AppRoutes.DISCOVER -> "Auto-find faces in your library" // ✨ NEW! AppRoutes.INVENTORY -> "Trained face models" AppRoutes.TRAIN -> "Add a new person to recognize" AppRoutes.TAGS -> "Organize your photo collection" diff --git a/app/src/main/java/com/placeholder/sherpai2/workers/Faceclusteringworker.kt b/app/src/main/java/com/placeholder/sherpai2/workers/Faceclusteringworker.kt new file mode 100644 index 0000000..48904d0 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/workers/Faceclusteringworker.kt @@ -0,0 +1,113 @@ +package com.placeholder.sherpai2.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.* +import com.placeholder.sherpai2.domain.clustering.FaceClusteringService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * FaceClusteringWorker - Background face clustering with persistence + * + * BENEFITS: + * - Survives app restarts + * - Runs even when app is backgrounded + * - Progress updates via WorkManager Data + * - Results saved to shared preferences + * + * USAGE: + * val workRequest = OneTimeWorkRequestBuilder() + * .setConstraints(...) + * .build() + * WorkManager.getInstance(context).enqueue(workRequest) + */ +@HiltWorker +class FaceClusteringWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val clusteringService: FaceClusteringService +) : CoroutineWorker(context, workerParams) { + + companion object { + const val WORK_NAME = "face_clustering_discovery" + const val KEY_PROGRESS_CURRENT = "progress_current" + const val KEY_PROGRESS_TOTAL = "progress_total" + const val KEY_PROGRESS_MESSAGE = "progress_message" + const val KEY_CLUSTER_COUNT = "cluster_count" + const val KEY_FACE_COUNT = "face_count" + const val KEY_RESULT_JSON = "result_json" + } + + override suspend fun doWork(): Result = withContext(Dispatchers.Default) { + try { + // Check if we should stop (work cancelled) + if (isStopped) { + return@withContext Result.failure() + } + + withContext(Dispatchers.Main) { + setProgress( + workDataOf( + KEY_PROGRESS_CURRENT to 0, + KEY_PROGRESS_TOTAL to 100, + KEY_PROGRESS_MESSAGE to "Starting discovery..." + ) + ) + } + + // Run clustering + val result = clusteringService.discoverPeople( + onProgress = { current, total, message -> + if (!isStopped) { + kotlinx.coroutines.runBlocking { + withContext(Dispatchers.Main) { + setProgress( + workDataOf( + KEY_PROGRESS_CURRENT to current, + KEY_PROGRESS_TOTAL to total, + KEY_PROGRESS_MESSAGE to message + ) + ) + } + } + } + } + ) + + // Save result to SharedPreferences for ViewModel to read + val prefs = context.getSharedPreferences("face_clustering", Context.MODE_PRIVATE) + prefs.edit().apply { + putInt(KEY_CLUSTER_COUNT, result.clusters.size) + putInt(KEY_FACE_COUNT, result.totalFacesAnalyzed) + putLong("timestamp", System.currentTimeMillis()) + // Don't serialize full result - too complex without proper setup + // Phase 2 will handle proper result persistence + apply() + } + + // Success! + Result.success( + workDataOf( + KEY_CLUSTER_COUNT to result.clusters.size, + KEY_FACE_COUNT to result.totalFacesAnalyzed + ) + ) + + } catch (e: Exception) { + // Save error state + val prefs = context.getSharedPreferences("face_clustering", Context.MODE_PRIVATE) + prefs.edit().apply { + putString("error", e.message ?: "Unknown error") + putLong("timestamp", System.currentTimeMillis()) + apply() + } + + Result.failure( + workDataOf("error" to (e.message ?: "Unknown error")) + ) + } + } +} \ No newline at end of file