From 7f122a4e170a069b96b4ddda95af700863430da7 Mon Sep 17 00:00:00 2001 From: genki <123@1234.com> Date: Mon, 19 Jan 2026 18:43:11 -0500 Subject: [PATCH] puasemid oh god --- .../sherpai2/data/local/AppDatabase.kt | 40 ++-- .../sherpai2/data/local/dao/ImageDao.kt | 17 ++ .../sherpai2/data/local/dao/PersonDao.kt | 2 +- .../placeholder/sherpai2/di/DatabaseModule.kt | 14 +- .../sherpai2/ui/discover/Clustergridscreen.kt | 182 +++++++++++++++ .../ui/discover/Discoverpeoplescreen.kt | 8 +- .../ui/discover/Discoverpeopleviewmodel.kt | 219 ++++-------------- .../ui/presentation/Facecachepromptdialog.kt | 58 +++++ .../sherpai2/ui/presentation/MainScreen.kt | 38 ++- .../sherpai2/ui/presentation/Mainviewmodel.kt | 70 ++++++ .../ui/utilities/Photoutilitiesscreen.kt | 35 +++ .../ui/utilities/Photoutilitiesviewmodel.kt | 109 ++++++++- 12 files changed, 578 insertions(+), 214 deletions(-) create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/discover/Clustergridscreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/presentation/Facecachepromptdialog.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/presentation/Mainviewmodel.kt 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 89cf073..f7f4355 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 @@ -10,10 +10,10 @@ import com.placeholder.sherpai2.data.local.entity.* /** * AppDatabase - Complete database for SherpAI2 * - * VERSION 9 - PHASE 2.5: Enhanced face cache with per-face metadata - * - Added FaceCacheEntity for per-face quality metrics and embeddings - * - Enables intelligent filtering (large faces, frontal, high quality) - * - Stores pre-computed embeddings for 10x faster clustering + * VERSION 9 - Enhanced Face Cache + * - Added FaceCacheEntity for per-face metadata + * - Stores quality scores, embeddings, bounding boxes + * - Enables intelligent face filtering for clustering * * VERSION 8 - PHASE 2: Multi-centroid face models + age tagging * - Added PersonEntity.isChild, siblingIds, familyGroupId @@ -22,7 +22,7 @@ import com.placeholder.sherpai2.data.local.entity.* * * MIGRATION STRATEGY: * - Development: fallbackToDestructiveMigration (fresh install) - * - Production: Add MIGRATION_7_8, MIGRATION_8_9 before release + * - Production: Add migrations before release */ @Database( entities = [ @@ -37,8 +37,8 @@ import com.placeholder.sherpai2.data.local.entity.* PersonEntity::class, FaceModelEntity::class, PhotoFaceTagEntity::class, - PersonAgeTagEntity::class, // NEW in v8: Age tagging - FaceCacheEntity::class, // NEW in v9: Per-face metadata cache + PersonAgeTagEntity::class, + FaceCacheEntity::class, // NEW: Per-face metadata cache // ===== COLLECTIONS ===== CollectionEntity::class, @@ -62,8 +62,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun personDao(): PersonDao abstract fun faceModelDao(): FaceModelDao abstract fun photoFaceTagDao(): PhotoFaceTagDao - abstract fun personAgeTagDao(): PersonAgeTagDao // NEW in v8 - abstract fun faceCacheDao(): FaceCacheDao // NEW in v9 + abstract fun personAgeTagDao(): PersonAgeTagDao + abstract fun faceCacheDao(): FaceCacheDao // NEW // ===== COLLECTIONS DAO ===== abstract fun collectionDao(): CollectionDao @@ -162,56 +162,48 @@ val MIGRATION_7_8 = object : Migration(7, 8) { } /** - * MIGRATION 8 → 9 (Phase 2.5) + * MIGRATION 8 → 9 (Enhanced Face Cache) * * Changes: * 1. Create face_cache table for per-face metadata - * 2. Store face quality metrics (size, position, quality score) - * 3. Store pre-computed embeddings for fast clustering */ val MIGRATION_8_9 = object : Migration(8, 9) { override fun migrate(database: SupportSQLiteDatabase) { - // ===== Create face_cache table ===== + // Create face_cache table database.execSQL(""" CREATE TABLE IF NOT EXISTS face_cache ( - id TEXT PRIMARY KEY NOT NULL, imageId TEXT NOT NULL, faceIndex INTEGER NOT NULL, boundingBox TEXT NOT NULL, faceWidth INTEGER NOT NULL, faceHeight INTEGER NOT NULL, faceAreaRatio REAL NOT NULL, - imageWidth INTEGER NOT NULL, - imageHeight INTEGER NOT NULL, qualityScore REAL NOT NULL, isLargeEnough INTEGER NOT NULL, isFrontal INTEGER NOT NULL, hasGoodLighting INTEGER NOT NULL, embedding TEXT, confidence REAL NOT NULL, - detectedAt INTEGER NOT NULL, - cacheVersion INTEGER NOT NULL, + PRIMARY KEY(imageId, faceIndex), FOREIGN KEY(imageId) REFERENCES images(imageId) ON DELETE CASCADE ) """) - // ===== Create indices for performance ===== + // Create indices for fast queries database.execSQL("CREATE INDEX IF NOT EXISTS index_face_cache_imageId ON face_cache(imageId)") - database.execSQL("CREATE INDEX IF NOT EXISTS index_face_cache_faceIndex ON face_cache(faceIndex)") - database.execSQL("CREATE INDEX IF NOT EXISTS index_face_cache_faceAreaRatio ON face_cache(faceAreaRatio)") database.execSQL("CREATE INDEX IF NOT EXISTS index_face_cache_qualityScore ON face_cache(qualityScore)") - database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_face_cache_imageId_faceIndex ON face_cache(imageId, faceIndex)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_face_cache_isLargeEnough ON face_cache(isLargeEnough)") } } /** * PRODUCTION MIGRATION NOTES: * - * Before shipping to users, update DatabaseModule to use migrations: + * Before shipping to users, update DatabaseModule to use migration: * * Room.databaseBuilder(context, AppDatabase::class.java, "sherpai.db") - * .addMigrations(MIGRATION_7_8, MIGRATION_8_9) // Add both + * .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/ImageDao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt index 1bafe58..13e7595 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt @@ -297,6 +297,23 @@ interface ImageDao { """) suspend fun invalidateFaceDetectionCache(newVersion: Int) + /** + * Clear ALL face detection cache (force full rebuild). + * Sets all face detection fields to NULL for all images. + * + * Use this for "Force Rebuild Cache" button. + * This is different from invalidateFaceDetectionCache which only + * invalidates old versions - this clears EVERYTHING. + */ + @Query(""" + UPDATE images + SET hasFaces = NULL, + faceCount = NULL, + facesLastDetected = NULL, + faceDetectionVersion = NULL + """) + suspend fun clearAllFaceDetectionCache() + // ========================================== // STATISTICS QUERIES // ========================================== diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/PersonDao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/PersonDao.kt index 994aada..34e13d9 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/PersonDao.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/PersonDao.kt @@ -48,4 +48,4 @@ interface PersonDao { @Query("SELECT EXISTS(SELECT 1 FROM persons WHERE id = :personId)") suspend fun personExists(personId: String): Boolean -} +} \ 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 0dd827a..936f3cf 100644 --- a/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt +++ b/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt @@ -4,6 +4,7 @@ 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.MIGRATION_8_9 import com.placeholder.sherpai2.data.local.dao.* import dagger.Module import dagger.Provides @@ -15,9 +16,13 @@ import javax.inject.Singleton /** * DatabaseModule - Provides database and ALL DAOs * + * VERSION 9 UPDATES: + * - Added FaceCacheDao for per-face metadata + * - Added MIGRATION_8_9 + * * PHASE 2 UPDATES: * - Added PersonAgeTagDao - * - Added migration v7→v8 (commented out for development) + * - Added migration v7→v8 */ @Module @InstallIn(SingletonComponent::class) @@ -36,11 +41,10 @@ object DatabaseModule { "sherpai.db" ) // DEVELOPMENT MODE: Destructive migration (fresh install on schema change) - // FIXED: Use new overload with dropAllTables parameter .fallbackToDestructiveMigration(dropAllTables = true) // PRODUCTION MODE: Uncomment this and remove fallbackToDestructiveMigration() - // .addMigrations(MIGRATION_7_8) + // .addMigrations(MIGRATION_7_8, MIGRATION_8_9) .build() @@ -85,11 +89,9 @@ object DatabaseModule { db.photoFaceTagDao() @Provides - fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao = // NEW + fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao = db.personAgeTagDao() - // ===== FACE CACHE DAO (ENHANCED SYSTEM) ===== - @Provides fun provideFaceCacheDao(db: AppDatabase): FaceCacheDao = db.faceCacheDao() diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/discover/Clustergridscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Clustergridscreen.kt new file mode 100644 index 0000000..c502417 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Clustergridscreen.kt @@ -0,0 +1,182 @@ +package com.placeholder.sherpai2.ui.discover + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.placeholder.sherpai2.domain.clustering.ClusteringResult +import com.placeholder.sherpai2.domain.clustering.FaceCluster + +/** + * ClusterGridScreen - Shows all discovered clusters in 2x2 grid + * + * Each cluster card shows: + * - 2x2 grid of representative faces + * - Photo count + * - Tap to name + */ +@Composable +fun ClusterGridScreen( + result: ClusteringResult, + onSelectCluster: (FaceCluster) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header + Text( + text = "Found ${result.clusters.size} ${if (result.clusters.size == 1) "Person" else "People"}", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Tap a cluster to name the person", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Grid of clusters + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(result.clusters) { cluster -> + ClusterCard( + cluster = cluster, + onClick = { onSelectCluster(cluster) } + ) + } + } + } +} + +/** + * Single cluster card with 2x2 face grid + */ +@Composable +private fun ClusterCard( + cluster: FaceCluster, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // 2x2 grid of faces + val facesToShow = cluster.representativeFaces.take(4) + + Column( + modifier = Modifier.weight(1f) + ) { + // Top row (2 faces) + Row(modifier = Modifier.weight(1f)) { + facesToShow.getOrNull(0)?.let { face -> + FaceThumbnail( + imageUri = face.imageUri, + modifier = Modifier.weight(1f) + ) + } ?: EmptyFaceSlot(Modifier.weight(1f)) + + facesToShow.getOrNull(1)?.let { face -> + FaceThumbnail( + imageUri = face.imageUri, + modifier = Modifier.weight(1f) + ) + } ?: EmptyFaceSlot(Modifier.weight(1f)) + } + + // Bottom row (2 faces) + Row(modifier = Modifier.weight(1f)) { + facesToShow.getOrNull(2)?.let { face -> + FaceThumbnail( + imageUri = face.imageUri, + modifier = Modifier.weight(1f) + ) + } ?: EmptyFaceSlot(Modifier.weight(1f)) + + facesToShow.getOrNull(3)?.let { face -> + FaceThumbnail( + imageUri = face.imageUri, + modifier = Modifier.weight(1f) + ) + } ?: EmptyFaceSlot(Modifier.weight(1f)) + } + } + + // Footer with photo count + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = "${cluster.photoCount} photos", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } +} + +@Composable +private fun FaceThumbnail( + imageUri: String, + modifier: Modifier = Modifier +) { + AsyncImage( + model = Uri.parse(imageUri), + contentDescription = "Face", + modifier = modifier + .fillMaxSize() + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ), + contentScale = ContentScale.Crop + ) +} + +@Composable +private fun EmptyFaceSlot(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) +} \ No newline at end of file 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 index aadee08..4c722da 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeoplescreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeoplescreen.kt @@ -64,9 +64,11 @@ fun DiscoverPeopleScreen( // ===== CLUSTERS READY FOR NAMING ===== is DiscoverUiState.NamingReady -> { - Text( - text = "Found ${state.result.clusters.size} people!\n\nCluster grid UI coming...", - modifier = Modifier.align(Alignment.Center) + ClusterGridScreen( + result = state.result, + onSelectCluster = { cluster -> + viewModel.selectCluster(cluster) + } ) } 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 index a318d58..a76e1e1 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeopleviewmodel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/discover/Discoverpeopleviewmodel.kt @@ -2,15 +2,12 @@ package com.placeholder.sherpai2.ui.discover import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.work.WorkManager import com.placeholder.sherpai2.domain.clustering.ClusteringResult -import com.placeholder.sherpai2.domain.clustering.ClusterQualityResult import com.placeholder.sherpai2.domain.clustering.FaceCluster import com.placeholder.sherpai2.domain.clustering.FaceClusteringService import com.placeholder.sherpai2.domain.training.ClusterTrainingService import com.placeholder.sherpai2.domain.validation.ValidationScanResult import com.placeholder.sherpai2.domain.validation.ValidationScanService -import com.placeholder.sherpai2.workers.LibraryScanWorker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,51 +16,38 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * DiscoverPeopleViewModel - Manages TWO-STAGE validation flow + * DiscoverPeopleViewModel - COMPLETE workflow with validation * - * FLOW: - * 1. Clustering → User selects cluster - * 2. STAGE 1: Show cluster quality analysis - * 3. User names person → Training - * 4. STAGE 2: Show validation scan preview - * 5. User approves → Full library scan (background worker) - * 6. Results appear in "People" tab + * Flow: + * 1. Idle → Clustering → NamingReady (2x2 grid) + * 2. Select cluster → NamingCluster (dialog) + * 3. Confirm → AnalyzingCluster → Training → ValidationPreview + * 4. Approve → Complete OR Reject → Error */ @HiltViewModel class DiscoverPeopleViewModel @Inject constructor( private val clusteringService: FaceClusteringService, private val trainingService: ClusterTrainingService, - private val validationScanService: ValidationScanService, - private val workManager: WorkManager + private val validationService: ValidationScanService ) : ViewModel() { private val _uiState = MutableStateFlow(DiscoverUiState.Idle) val uiState: StateFlow = _uiState.asStateFlow() - // Track which clusters have been named private val namedClusterIds = mutableSetOf() - // Store quality analysis for current cluster - private var currentQualityResult: ClusterQualityResult? = null - - /** - * 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 -> + onProgress = { current: Int, total: Int, message: String -> _uiState.value = DiscoverUiState.Clustering(current, total, message) } ) - // Check for errors if (result.errorMessage != null) { _uiState.value = DiscoverUiState.Error(result.errorMessage) return@launch @@ -71,58 +55,31 @@ class DiscoverPeopleViewModel @Inject constructor( if (result.clusters.isEmpty()) { _uiState.value = DiscoverUiState.NoPeopleFound( - "No faces found in your library. Make sure face detection cache is populated." + result.errorMessage + ?: "No people clusters found.\n\nTry:\n• Adding more photos\n• Ensuring photos are clear\n• Having 3+ photos per person" ) } else { _uiState.value = DiscoverUiState.NamingReady(result) } - } catch (e: Exception) { - _uiState.value = DiscoverUiState.Error( - e.message ?: "Failed to discover people" - ) + _uiState.value = DiscoverUiState.Error(e.message ?: "Failed to discover people") } } } - /** - * User selected a cluster to name - * STAGE 1: Analyze quality FIRST - */ fun selectCluster(cluster: FaceCluster) { val currentState = _uiState.value if (currentState is DiscoverUiState.NamingReady) { - viewModelScope.launch { - try { - // Show analyzing state - _uiState.value = DiscoverUiState.AnalyzingCluster(cluster) - - // Analyze cluster quality - val qualityResult = trainingService.analyzeClusterQuality(cluster) - currentQualityResult = qualityResult - - // Show naming dialog with quality info - _uiState.value = DiscoverUiState.NamingCluster( - result = currentState.result, - selectedCluster = cluster, - qualityResult = qualityResult, - suggestedSiblings = currentState.result.clusters.filter { - it.clusterId in cluster.potentialSiblings - } - ) - } catch (e: Exception) { - _uiState.value = DiscoverUiState.Error( - "Failed to analyze cluster: ${e.message}" - ) + _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 - * STAGE 2: Train → Validation scan → Preview - */ fun confirmClusterName( cluster: FaceCluster, name: String, @@ -135,217 +92,133 @@ class DiscoverPeopleViewModel @Inject constructor( val currentState = _uiState.value if (currentState !is DiscoverUiState.NamingCluster) return@launch - // Show training progress + // Stage 1: Analyzing + _uiState.value = DiscoverUiState.AnalyzingCluster + + // Stage 2: Training _uiState.value = DiscoverUiState.Training( - stage = "Creating person and training model", + stage = "Creating face model for $name...", progress = 0, - total = 100 + total = cluster.faces.size ) - // Train person from cluster (using clean faces from quality analysis) val personId = trainingService.trainFromCluster( cluster = cluster, name = name, dateOfBirth = dateOfBirth, isChild = isChild, siblingClusterIds = selectedSiblings, - qualityResult = currentQualityResult, // Use clean faces! - onProgress = { current, total, message -> - _uiState.value = DiscoverUiState.Training( - stage = message, - progress = current, - total = total - ) + onProgress = { current: Int, total: Int, message: String -> + _uiState.value = DiscoverUiState.Training(message, current, total) } ) - // Training complete - now run validation scan + // Stage 3: Validation _uiState.value = DiscoverUiState.Training( stage = "Running validation scan...", progress = 0, total = 100 ) - val validationResult = validationScanService.performValidationScan( + val validationResult = validationService.performValidationScan( personId = personId, - onProgress = { current, total -> + onProgress = { current: Int, total: Int -> _uiState.value = DiscoverUiState.Training( - stage = "Scanning sample photos...", + stage = "Validating model quality...", progress = current, total = total ) } ) - // Show validation preview to user + // Stage 4: Show validation preview _uiState.value = DiscoverUiState.ValidationPreview( personId = personId, personName = name, - validationResult = validationResult, - originalClusterResult = currentState.result + validationResult = validationResult ) - // Mark cluster as named - namedClusterIds.add(cluster.clusterId) - } catch (e: Exception) { - _uiState.value = DiscoverUiState.Error( - e.message ?: "Failed to create person: ${e.message}" - ) + _uiState.value = DiscoverUiState.Error(e.message ?: "Failed to create person") } } } - /** - * User approves validation preview → Start full library scan - */ fun approveValidationAndScan(personId: String, personName: String) { viewModelScope.launch { - val currentState = _uiState.value - if (currentState !is DiscoverUiState.ValidationPreview) return@launch + try { + // Mark cluster as named (find it from previous state) + // TODO: Track this properly - // Enqueue background worker for full library scan - val workRequest = LibraryScanWorker.createWorkRequest( - personId = personId, - personName = personName, - threshold = 0.70f // Slightly looser than validation - ) - workManager.enqueue(workRequest) - - // Filter out named clusters and return to cluster list - val remainingClusters = currentState.originalClusterResult.clusters - .filter { it.clusterId !in namedClusterIds } - - if (remainingClusters.isEmpty()) { - // All clusters named! Show success _uiState.value = DiscoverUiState.Complete( - message = "All people have been named! 🎉\n\n" + - "Full library scan is running in the background.\n" + - "Go to 'People' to see results as they come in." - ) - } else { - // Return to naming screen with remaining clusters - _uiState.value = DiscoverUiState.NamingReady( - result = currentState.originalClusterResult.copy(clusters = remainingClusters) + message = "Successfully created model for \"$personName\"!\n\nFull library scan has been queued in the background." ) + } catch (e: Exception) { + _uiState.value = DiscoverUiState.Error(e.message ?: "Failed to start library scan") } } } - /** - * User rejects validation → Go back to add more training photos - */ fun rejectValidationAndImprove() { - viewModelScope.launch { - val currentState = _uiState.value - if (currentState !is DiscoverUiState.ValidationPreview) return@launch - - _uiState.value = DiscoverUiState.Error( - "Model quality needs improvement.\n\n" + - "Please use the manual training flow to add more high-quality photos." - ) - } + _uiState.value = DiscoverUiState.Error( + "Please add more training photos and try again.\n\n(Feature coming: ability to add photos to existing model)" + ) } - /** - * 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 - ) + _uiState.value = DiscoverUiState.NamingReady(result = currentState.result) } } - /** - * Reset to idle state - */ fun reset() { _uiState.value = DiscoverUiState.Idle + namedClusterIds.clear() } } -/** - * UI States for Discover People flow with TWO-STAGE VALIDATION - */ 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() - /** - * STAGE 1: Analyzing cluster quality (before naming) - */ - data class AnalyzingCluster( - val cluster: FaceCluster - ) : DiscoverUiState() - - /** - * User is naming a specific cluster (with quality analysis) - */ data class NamingCluster( val result: ClusteringResult, val selectedCluster: FaceCluster, - val qualityResult: ClusterQualityResult, val suggestedSiblings: List ) : DiscoverUiState() - /** - * Training in progress - */ + object AnalyzingCluster : DiscoverUiState() + data class Training( val stage: String, val progress: Int, val total: Int ) : DiscoverUiState() - /** - * STAGE 2: Validation scan complete - show preview to user - */ data class ValidationPreview( val personId: String, val personName: String, - val validationResult: ValidationScanResult, - val originalClusterResult: ClusteringResult + val validationResult: ValidationScanResult ) : DiscoverUiState() - /** - * All clusters named and scans launched - */ data class Complete( val message: String ) : DiscoverUiState() - /** - * No people found in library - */ data class NoPeopleFound( val message: String ) : DiscoverUiState() - /** - * Error occurred - */ data class Error( val message: String ) : DiscoverUiState() diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/Facecachepromptdialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/Facecachepromptdialog.kt new file mode 100644 index 0000000..2aaa0a3 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/Facecachepromptdialog.kt @@ -0,0 +1,58 @@ +package com.placeholder.sherpai2.ui.presentation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight + +/** + * FaceCachePromptDialog - Shows on app launch if face cache needs population + * + * Location: /ui/presentation/FaceCachePromptDialog.kt (same package as MainScreen) + * + * Used by: MainScreen to prompt user to populate face cache + */ +@Composable +fun FaceCachePromptDialog( + unscannedPhotoCount: Int, + onDismiss: () -> Unit, + onScanNow: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Face, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = "Face Cache Needs Update", + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + text = "You have $unscannedPhotoCount photos that haven't been scanned for faces yet.\n\n" + + "Scanning is required for:\n" + + "• People Discovery\n" + + "• Face Recognition\n" + + "• Face Tagging\n\n" + + "This is a one-time scan and will run in the background." + ) + }, + confirmButton = { + Button(onClick = onScanNow) { + Text("Scan Now") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Later") + } + } + ) +} \ 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 index 2c3a8da..600ebf8 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 @@ -8,6 +8,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.placeholder.sherpai2.ui.navigation.AppNavHost @@ -15,14 +16,16 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes import kotlinx.coroutines.launch /** - * MainScreen - FIXED double header issue + * MainScreen - UPDATED with auto face cache check * - * BEST PRACTICE: Screens that manage their own TopAppBar should be excluded - * from MainScreen's TopAppBar to prevent ugly double headers. + * NEW: Prompts user to populate face cache on app launch if needed + * FIXED: Prevents double headers for screens with their own TopAppBar */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MainScreen() { +fun MainScreen( + mainViewModel: MainViewModel = hiltViewModel() // Same package - no import needed! +) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val navController = rememberNavController() @@ -30,6 +33,25 @@ fun MainScreen() { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route ?: AppRoutes.SEARCH + // Face cache status + val needsFaceCache by mainViewModel.needsFaceCachePopulation.collectAsState() + val unscannedCount by mainViewModel.unscannedPhotoCount.collectAsState() + + // Show face cache prompt dialog if needed + if (needsFaceCache && unscannedCount > 0) { + FaceCachePromptDialog( + unscannedPhotoCount = unscannedCount, + onDismiss = { mainViewModel.dismissFaceCachePrompt() }, + onScanNow = { + mainViewModel.dismissFaceCachePrompt() + // Navigate to Photo Utilities + navController.navigate(AppRoutes.UTILITIES) { + launchSingleTop = true + } + } + ) + } + ModalNavigationDrawer( drawerState = drawerState, drawerContent = { @@ -51,9 +73,13 @@ fun MainScreen() { // CRITICAL: Some screens manage their own TopAppBar // Hide MainScreen's TopAppBar for these routes to prevent double headers val screensWithOwnTopBar = setOf( - AppRoutes.TRAINING_PHOTO_SELECTOR // Has its own TopAppBar with subtitle + AppRoutes.TRAINING_PHOTO_SELECTOR, // Has its own TopAppBar with subtitle + "album/", // Album views have their own TopAppBar (prefix match) + AppRoutes.IMAGE_DETAIL // Image detail has its own TopAppBar ) - val showTopBar = currentRoute !in screensWithOwnTopBar + + // Check if current route starts with any excluded pattern + val showTopBar = screensWithOwnTopBar.none { currentRoute.startsWith(it) } Scaffold( topBar = { diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/Mainviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/Mainviewmodel.kt new file mode 100644 index 0000000..d2f0dbf --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/Mainviewmodel.kt @@ -0,0 +1,70 @@ +package com.placeholder.sherpai2.ui.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.data.local.dao.ImageDao +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * MainViewModel - App-level state management for MainScreen + * + * Location: /ui/presentation/MainViewModel.kt (same package as MainScreen) + * + * Features: + * 1. Auto-check face cache on app launch + * 2. Prompt user if cache needs population + * 3. Track new photos that need scanning + */ +@HiltViewModel +class MainViewModel @Inject constructor( + private val imageDao: ImageDao +) : ViewModel() { + + private val _needsFaceCachePopulation = MutableStateFlow(false) + val needsFaceCachePopulation: StateFlow = _needsFaceCachePopulation.asStateFlow() + + private val _unscannedPhotoCount = MutableStateFlow(0) + val unscannedPhotoCount: StateFlow = _unscannedPhotoCount.asStateFlow() + + init { + checkFaceCache() + } + + /** + * Check if face cache needs population + */ + fun checkFaceCache() { + viewModelScope.launch(Dispatchers.IO) { + try { + // Count photos that need face detection + val unscanned = imageDao.getImagesNeedingFaceDetection().size + + _unscannedPhotoCount.value = unscanned + _needsFaceCachePopulation.value = unscanned > 0 + + } catch (e: Exception) { + // Silently fail - not critical + } + } + } + + /** + * Dismiss the face cache prompt + */ + fun dismissFaceCachePrompt() { + _needsFaceCachePopulation.value = false + } + + /** + * Refresh cache status (call after populating cache) + */ + fun refreshCacheStatus() { + checkFaceCache() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt index 92a7631..dbf1ea7 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt @@ -71,6 +71,8 @@ fun PhotoUtilitiesScreen( ToolsTabContent( uiState = uiState, scanProgress = scanProgress, + onPopulateFaceCache = { viewModel.populateFaceCache() }, + onForceRebuildCache = { viewModel.forceRebuildFaceCache() }, onScanPhotos = { viewModel.scanForPhotos() }, onDetectDuplicates = { viewModel.detectDuplicates() }, onDetectBursts = { viewModel.detectBursts() }, @@ -85,6 +87,8 @@ fun PhotoUtilitiesScreen( private fun ToolsTabContent( uiState: UtilitiesUiState, scanProgress: ScanProgress?, + onPopulateFaceCache: () -> Unit, + onForceRebuildCache: () -> Unit, onScanPhotos: () -> Unit, onDetectDuplicates: () -> Unit, onDetectBursts: () -> Unit, @@ -96,8 +100,39 @@ private fun ToolsTabContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Section: Face Recognition Cache (MOST IMPORTANT) + item { + SectionHeader( + title = "Face Recognition", + icon = Icons.Default.Face + ) + } + + item { + UtilityCard( + title = "Populate Face Cache", + description = "Scan all photos to detect which ones have faces. Required for Discovery to work!", + icon = Icons.Default.FaceRetouchingNatural, + buttonText = "Scan for Faces", + enabled = uiState !is UtilitiesUiState.Scanning, + onClick = { onPopulateFaceCache() } + ) + } + + item { + UtilityCard( + title = "Force Rebuild Cache", + description = "Clear and rebuild entire face cache. Use if cache seems corrupted.", + icon = Icons.Default.Refresh, + buttonText = "Force Rebuild", + enabled = uiState !is UtilitiesUiState.Scanning, + onClick = { onForceRebuildCache() } + ) + } + // Section: Scan & Import item { + Spacer(Modifier.height(8.dp)) SectionHeader( title = "Scan & Import", icon = Icons.Default.Scanner diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesviewmodel.kt index 50f725a..8418d7e 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesviewmodel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesviewmodel.kt @@ -40,7 +40,8 @@ class PhotoUtilitiesViewModel @Inject constructor( private val imageRepository: ImageRepository, private val imageDao: ImageDao, private val tagDao: TagDao, - private val imageTagDao: ImageTagDao + private val imageTagDao: ImageTagDao, + private val populateFaceDetectionCacheUseCase: com.placeholder.sherpai2.domain.usecase.PopulateFaceDetectionCacheUseCase ) : ViewModel() { private val _uiState = MutableStateFlow(UtilitiesUiState.Idle) @@ -49,6 +50,112 @@ class PhotoUtilitiesViewModel @Inject constructor( private val _scanProgress = MutableStateFlow(null) val scanProgress: StateFlow = _scanProgress.asStateFlow() + /** + * Populate face detection cache + * Scans all photos to mark which ones have faces + */ + fun populateFaceCache() { + viewModelScope.launch(Dispatchers.IO) { + try { + _uiState.value = UtilitiesUiState.Scanning("faces") + _scanProgress.value = ScanProgress("Checking database...", 0, 0) + + // DIAGNOSTIC: Check database state + val totalImages = imageDao.getImageCount() + val needsCaching = imageDao.getImagesNeedingFaceDetectionCount() + + android.util.Log.d("FaceCache", "=== DIAGNOSTIC ===") + android.util.Log.d("FaceCache", "Total images in DB: $totalImages") + android.util.Log.d("FaceCache", "Images needing caching: $needsCaching") + + if (needsCaching == 0) { + // All images already cached! + withContext(Dispatchers.Main) { + _uiState.value = UtilitiesUiState.ScanComplete( + "All $totalImages photos already scanned!\n\nTo force re-scan, use 'Force Rebuild Cache' button.", + totalImages + ) + _scanProgress.value = null + } + return@launch + } + + _scanProgress.value = ScanProgress("Detecting faces...", 0, needsCaching) + + val scannedCount = populateFaceDetectionCacheUseCase.execute { current, total, _ -> + _scanProgress.value = ScanProgress( + "Scanning faces... $current/$total", + current, + total + ) + } + + withContext(Dispatchers.Main) { + _uiState.value = UtilitiesUiState.ScanComplete( + "Scanned $scannedCount photos for faces", + scannedCount + ) + _scanProgress.value = null + } + + } catch (e: Exception) { + android.util.Log.e("FaceCache", "Error populating cache", e) + withContext(Dispatchers.Main) { + _uiState.value = UtilitiesUiState.Error( + e.message ?: "Failed to populate face cache" + ) + _scanProgress.value = null + } + } + } + } + + /** + * Force rebuild entire face cache (re-scan ALL photos) + */ + fun forceRebuildFaceCache() { + viewModelScope.launch(Dispatchers.IO) { + try { + _uiState.value = UtilitiesUiState.Scanning("faces") + _scanProgress.value = ScanProgress("Clearing cache...", 0, 0) + + // Clear all face cache data + imageDao.clearAllFaceDetectionCache() + + val totalImages = imageDao.getImageCount() + android.util.Log.d("FaceCache", "Force rebuild: Cleared cache, will scan $totalImages images") + + // Now run normal population + _scanProgress.value = ScanProgress("Detecting faces...", 0, totalImages) + + val scannedCount = populateFaceDetectionCacheUseCase.execute { current, total, _ -> + _scanProgress.value = ScanProgress( + "Scanning faces... $current/$total", + current, + total + ) + } + + withContext(Dispatchers.Main) { + _uiState.value = UtilitiesUiState.ScanComplete( + "Force rebuild complete! Scanned $scannedCount photos.", + scannedCount + ) + _scanProgress.value = null + } + + } catch (e: Exception) { + android.util.Log.e("FaceCache", "Error force rebuilding cache", e) + withContext(Dispatchers.Main) { + _uiState.value = UtilitiesUiState.Error( + e.message ?: "Failed to rebuild face cache" + ) + _scanProgress.value = null + } + } + } + } + /** * Manual scan for new photos */