puasemid oh god

This commit is contained in:
genki
2026-01-19 18:43:11 -05:00
parent 6eef06c4c1
commit 7f122a4e17
12 changed files with 578 additions and 214 deletions

View File

@@ -10,10 +10,10 @@ import com.placeholder.sherpai2.data.local.entity.*
/** /**
* AppDatabase - Complete database for SherpAI2 * AppDatabase - Complete database for SherpAI2
* *
* VERSION 9 - PHASE 2.5: Enhanced face cache with per-face metadata * VERSION 9 - Enhanced Face Cache
* - Added FaceCacheEntity for per-face quality metrics and embeddings * - Added FaceCacheEntity for per-face metadata
* - Enables intelligent filtering (large faces, frontal, high quality) * - Stores quality scores, embeddings, bounding boxes
* - Stores pre-computed embeddings for 10x faster clustering * - Enables intelligent face filtering for clustering
* *
* VERSION 8 - PHASE 2: Multi-centroid face models + age tagging * VERSION 8 - PHASE 2: Multi-centroid face models + age tagging
* - Added PersonEntity.isChild, siblingIds, familyGroupId * - Added PersonEntity.isChild, siblingIds, familyGroupId
@@ -22,7 +22,7 @@ import com.placeholder.sherpai2.data.local.entity.*
* *
* MIGRATION STRATEGY: * MIGRATION STRATEGY:
* - Development: fallbackToDestructiveMigration (fresh install) * - Development: fallbackToDestructiveMigration (fresh install)
* - Production: Add MIGRATION_7_8, MIGRATION_8_9 before release * - Production: Add migrations before release
*/ */
@Database( @Database(
entities = [ entities = [
@@ -37,8 +37,8 @@ import com.placeholder.sherpai2.data.local.entity.*
PersonEntity::class, PersonEntity::class,
FaceModelEntity::class, FaceModelEntity::class,
PhotoFaceTagEntity::class, PhotoFaceTagEntity::class,
PersonAgeTagEntity::class, // NEW in v8: Age tagging PersonAgeTagEntity::class,
FaceCacheEntity::class, // NEW in v9: Per-face metadata cache FaceCacheEntity::class, // NEW: Per-face metadata cache
// ===== COLLECTIONS ===== // ===== COLLECTIONS =====
CollectionEntity::class, CollectionEntity::class,
@@ -62,8 +62,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun personDao(): PersonDao abstract fun personDao(): PersonDao
abstract fun faceModelDao(): FaceModelDao abstract fun faceModelDao(): FaceModelDao
abstract fun photoFaceTagDao(): PhotoFaceTagDao abstract fun photoFaceTagDao(): PhotoFaceTagDao
abstract fun personAgeTagDao(): PersonAgeTagDao // NEW in v8 abstract fun personAgeTagDao(): PersonAgeTagDao
abstract fun faceCacheDao(): FaceCacheDao // NEW in v9 abstract fun faceCacheDao(): FaceCacheDao // NEW
// ===== COLLECTIONS DAO ===== // ===== COLLECTIONS DAO =====
abstract fun collectionDao(): CollectionDao 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: * Changes:
* 1. Create face_cache table for per-face metadata * 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) { val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
// ===== Create face_cache table ===== // Create face_cache table
database.execSQL(""" database.execSQL("""
CREATE TABLE IF NOT EXISTS face_cache ( CREATE TABLE IF NOT EXISTS face_cache (
id TEXT PRIMARY KEY NOT NULL,
imageId TEXT NOT NULL, imageId TEXT NOT NULL,
faceIndex INTEGER NOT NULL, faceIndex INTEGER NOT NULL,
boundingBox TEXT NOT NULL, boundingBox TEXT NOT NULL,
faceWidth INTEGER NOT NULL, faceWidth INTEGER NOT NULL,
faceHeight INTEGER NOT NULL, faceHeight INTEGER NOT NULL,
faceAreaRatio REAL NOT NULL, faceAreaRatio REAL NOT NULL,
imageWidth INTEGER NOT NULL,
imageHeight INTEGER NOT NULL,
qualityScore REAL NOT NULL, qualityScore REAL NOT NULL,
isLargeEnough INTEGER NOT NULL, isLargeEnough INTEGER NOT NULL,
isFrontal INTEGER NOT NULL, isFrontal INTEGER NOT NULL,
hasGoodLighting INTEGER NOT NULL, hasGoodLighting INTEGER NOT NULL,
embedding TEXT, embedding TEXT,
confidence REAL NOT NULL, confidence REAL NOT NULL,
detectedAt INTEGER NOT NULL, PRIMARY KEY(imageId, faceIndex),
cacheVersion INTEGER NOT NULL,
FOREIGN KEY(imageId) REFERENCES images(imageId) ON DELETE CASCADE 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_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 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: * 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") * 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 * // .fallbackToDestructiveMigration() // Remove this
* .build() * .build()
*/ */

View File

@@ -297,6 +297,23 @@ interface ImageDao {
""") """)
suspend fun invalidateFaceDetectionCache(newVersion: Int) 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 // STATISTICS QUERIES
// ========================================== // ==========================================

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.placeholder.sherpai2.data.local.AppDatabase import com.placeholder.sherpai2.data.local.AppDatabase
import com.placeholder.sherpai2.data.local.MIGRATION_7_8 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 com.placeholder.sherpai2.data.local.dao.*
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -15,9 +16,13 @@ import javax.inject.Singleton
/** /**
* DatabaseModule - Provides database and ALL DAOs * DatabaseModule - Provides database and ALL DAOs
* *
* VERSION 9 UPDATES:
* - Added FaceCacheDao for per-face metadata
* - Added MIGRATION_8_9
*
* PHASE 2 UPDATES: * PHASE 2 UPDATES:
* - Added PersonAgeTagDao * - Added PersonAgeTagDao
* - Added migration v7→v8 (commented out for development) * - Added migration v7→v8
*/ */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -36,11 +41,10 @@ object DatabaseModule {
"sherpai.db" "sherpai.db"
) )
// DEVELOPMENT MODE: Destructive migration (fresh install on schema change) // DEVELOPMENT MODE: Destructive migration (fresh install on schema change)
// FIXED: Use new overload with dropAllTables parameter
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
// PRODUCTION MODE: Uncomment this and remove fallbackToDestructiveMigration() // PRODUCTION MODE: Uncomment this and remove fallbackToDestructiveMigration()
// .addMigrations(MIGRATION_7_8) // .addMigrations(MIGRATION_7_8, MIGRATION_8_9)
.build() .build()
@@ -85,11 +89,9 @@ object DatabaseModule {
db.photoFaceTagDao() db.photoFaceTagDao()
@Provides @Provides
fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao = // NEW fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao =
db.personAgeTagDao() db.personAgeTagDao()
// ===== FACE CACHE DAO (ENHANCED SYSTEM) =====
@Provides @Provides
fun provideFaceCacheDao(db: AppDatabase): FaceCacheDao = fun provideFaceCacheDao(db: AppDatabase): FaceCacheDao =
db.faceCacheDao() db.faceCacheDao()

View File

@@ -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)
)
)
}

View File

@@ -64,9 +64,11 @@ fun DiscoverPeopleScreen(
// ===== CLUSTERS READY FOR NAMING ===== // ===== CLUSTERS READY FOR NAMING =====
is DiscoverUiState.NamingReady -> { is DiscoverUiState.NamingReady -> {
Text( ClusterGridScreen(
text = "Found ${state.result.clusters.size} people!\n\nCluster grid UI coming...", result = state.result,
modifier = Modifier.align(Alignment.Center) onSelectCluster = { cluster ->
viewModel.selectCluster(cluster)
}
) )
} }

View File

@@ -2,15 +2,12 @@ package com.placeholder.sherpai2.ui.discover
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.placeholder.sherpai2.domain.clustering.ClusteringResult 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.FaceCluster
import com.placeholder.sherpai2.domain.clustering.FaceClusteringService import com.placeholder.sherpai2.domain.clustering.FaceClusteringService
import com.placeholder.sherpai2.domain.training.ClusterTrainingService import com.placeholder.sherpai2.domain.training.ClusterTrainingService
import com.placeholder.sherpai2.domain.validation.ValidationScanResult import com.placeholder.sherpai2.domain.validation.ValidationScanResult
import com.placeholder.sherpai2.domain.validation.ValidationScanService import com.placeholder.sherpai2.domain.validation.ValidationScanService
import com.placeholder.sherpai2.workers.LibraryScanWorker
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -19,51 +16,38 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
* DiscoverPeopleViewModel - Manages TWO-STAGE validation flow * DiscoverPeopleViewModel - COMPLETE workflow with validation
* *
* FLOW: * Flow:
* 1. Clustering → User selects cluster * 1. Idle → Clustering → NamingReady (2x2 grid)
* 2. STAGE 1: Show cluster quality analysis * 2. Select cluster → NamingCluster (dialog)
* 3. User names person → Training * 3. Confirm → AnalyzingCluster → Training → ValidationPreview
* 4. STAGE 2: Show validation scan preview * 4. Approve → Complete OR Reject → Error
* 5. User approves → Full library scan (background worker)
* 6. Results appear in "People" tab
*/ */
@HiltViewModel @HiltViewModel
class DiscoverPeopleViewModel @Inject constructor( class DiscoverPeopleViewModel @Inject constructor(
private val clusteringService: FaceClusteringService, private val clusteringService: FaceClusteringService,
private val trainingService: ClusterTrainingService, private val trainingService: ClusterTrainingService,
private val validationScanService: ValidationScanService, private val validationService: ValidationScanService
private val workManager: WorkManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<DiscoverUiState>(DiscoverUiState.Idle) private val _uiState = MutableStateFlow<DiscoverUiState>(DiscoverUiState.Idle)
val uiState: StateFlow<DiscoverUiState> = _uiState.asStateFlow() val uiState: StateFlow<DiscoverUiState> = _uiState.asStateFlow()
// Track which clusters have been named
private val namedClusterIds = mutableSetOf<Int>() private val namedClusterIds = mutableSetOf<Int>()
// Store quality analysis for current cluster
private var currentQualityResult: ClusterQualityResult? = null
/**
* Start auto-clustering process
*/
fun startDiscovery() { fun startDiscovery() {
viewModelScope.launch { viewModelScope.launch {
try { try {
// Clear named clusters for new discovery
namedClusterIds.clear() namedClusterIds.clear()
_uiState.value = DiscoverUiState.Clustering(0, 100, "Starting...") _uiState.value = DiscoverUiState.Clustering(0, 100, "Starting...")
val result = clusteringService.discoverPeople( val result = clusteringService.discoverPeople(
onProgress = { current, total, message -> onProgress = { current: Int, total: Int, message: String ->
_uiState.value = DiscoverUiState.Clustering(current, total, message) _uiState.value = DiscoverUiState.Clustering(current, total, message)
} }
) )
// Check for errors
if (result.errorMessage != null) { if (result.errorMessage != null) {
_uiState.value = DiscoverUiState.Error(result.errorMessage) _uiState.value = DiscoverUiState.Error(result.errorMessage)
return@launch return@launch
@@ -71,58 +55,31 @@ class DiscoverPeopleViewModel @Inject constructor(
if (result.clusters.isEmpty()) { if (result.clusters.isEmpty()) {
_uiState.value = DiscoverUiState.NoPeopleFound( _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 { } else {
_uiState.value = DiscoverUiState.NamingReady(result) _uiState.value = DiscoverUiState.NamingReady(result)
} }
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = DiscoverUiState.Error( _uiState.value = DiscoverUiState.Error(e.message ?: "Failed to discover people")
e.message ?: "Failed to discover people"
)
} }
} }
} }
/**
* User selected a cluster to name
* STAGE 1: Analyze quality FIRST
*/
fun selectCluster(cluster: FaceCluster) { fun selectCluster(cluster: FaceCluster) {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is DiscoverUiState.NamingReady) { if (currentState is DiscoverUiState.NamingReady) {
viewModelScope.launch { _uiState.value = DiscoverUiState.NamingCluster(
try { result = currentState.result,
// Show analyzing state selectedCluster = cluster,
_uiState.value = DiscoverUiState.AnalyzingCluster(cluster) suggestedSiblings = currentState.result.clusters.filter {
it.clusterId in cluster.potentialSiblings
// 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}"
)
} }
} )
} }
} }
/**
* User confirmed name and metadata for a cluster
* STAGE 2: Train → Validation scan → Preview
*/
fun confirmClusterName( fun confirmClusterName(
cluster: FaceCluster, cluster: FaceCluster,
name: String, name: String,
@@ -135,217 +92,133 @@ class DiscoverPeopleViewModel @Inject constructor(
val currentState = _uiState.value val currentState = _uiState.value
if (currentState !is DiscoverUiState.NamingCluster) return@launch if (currentState !is DiscoverUiState.NamingCluster) return@launch
// Show training progress // Stage 1: Analyzing
_uiState.value = DiscoverUiState.AnalyzingCluster
// Stage 2: Training
_uiState.value = DiscoverUiState.Training( _uiState.value = DiscoverUiState.Training(
stage = "Creating person and training model", stage = "Creating face model for $name...",
progress = 0, progress = 0,
total = 100 total = cluster.faces.size
) )
// Train person from cluster (using clean faces from quality analysis)
val personId = trainingService.trainFromCluster( val personId = trainingService.trainFromCluster(
cluster = cluster, cluster = cluster,
name = name, name = name,
dateOfBirth = dateOfBirth, dateOfBirth = dateOfBirth,
isChild = isChild, isChild = isChild,
siblingClusterIds = selectedSiblings, siblingClusterIds = selectedSiblings,
qualityResult = currentQualityResult, // Use clean faces! onProgress = { current: Int, total: Int, message: String ->
onProgress = { current, total, message -> _uiState.value = DiscoverUiState.Training(message, current, total)
_uiState.value = DiscoverUiState.Training(
stage = message,
progress = current,
total = total
)
} }
) )
// Training complete - now run validation scan // Stage 3: Validation
_uiState.value = DiscoverUiState.Training( _uiState.value = DiscoverUiState.Training(
stage = "Running validation scan...", stage = "Running validation scan...",
progress = 0, progress = 0,
total = 100 total = 100
) )
val validationResult = validationScanService.performValidationScan( val validationResult = validationService.performValidationScan(
personId = personId, personId = personId,
onProgress = { current, total -> onProgress = { current: Int, total: Int ->
_uiState.value = DiscoverUiState.Training( _uiState.value = DiscoverUiState.Training(
stage = "Scanning sample photos...", stage = "Validating model quality...",
progress = current, progress = current,
total = total total = total
) )
} }
) )
// Show validation preview to user // Stage 4: Show validation preview
_uiState.value = DiscoverUiState.ValidationPreview( _uiState.value = DiscoverUiState.ValidationPreview(
personId = personId, personId = personId,
personName = name, personName = name,
validationResult = validationResult, validationResult = validationResult
originalClusterResult = currentState.result
) )
// Mark cluster as named
namedClusterIds.add(cluster.clusterId)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = DiscoverUiState.Error( _uiState.value = DiscoverUiState.Error(e.message ?: "Failed to create person")
e.message ?: "Failed to create person: ${e.message}"
)
} }
} }
} }
/**
* User approves validation preview → Start full library scan
*/
fun approveValidationAndScan(personId: String, personName: String) { fun approveValidationAndScan(personId: String, personName: String) {
viewModelScope.launch { viewModelScope.launch {
val currentState = _uiState.value try {
if (currentState !is DiscoverUiState.ValidationPreview) return@launch // 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( _uiState.value = DiscoverUiState.Complete(
message = "All people have been named! 🎉\n\n" + message = "Successfully created model for \"$personName\"!\n\nFull library scan has been queued in the background."
"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)
) )
} 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() { fun rejectValidationAndImprove() {
viewModelScope.launch { _uiState.value = DiscoverUiState.Error(
val currentState = _uiState.value "Please add more training photos and try again.\n\n(Feature coming: ability to add photos to existing model)"
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."
)
}
} }
/**
* Cancel naming and go back to cluster list
*/
fun cancelNaming() { fun cancelNaming() {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is DiscoverUiState.NamingCluster) { if (currentState is DiscoverUiState.NamingCluster) {
_uiState.value = DiscoverUiState.NamingReady( _uiState.value = DiscoverUiState.NamingReady(result = currentState.result)
result = currentState.result
)
} }
} }
/**
* Reset to idle state
*/
fun reset() { fun reset() {
_uiState.value = DiscoverUiState.Idle _uiState.value = DiscoverUiState.Idle
namedClusterIds.clear()
} }
} }
/**
* UI States for Discover People flow with TWO-STAGE VALIDATION
*/
sealed class DiscoverUiState { sealed class DiscoverUiState {
/**
* Initial state - user hasn't started discovery
*/
object Idle : DiscoverUiState() object Idle : DiscoverUiState()
/**
* Auto-clustering in progress
*/
data class Clustering( data class Clustering(
val progress: Int, val progress: Int,
val total: Int, val total: Int,
val message: String val message: String
) : DiscoverUiState() ) : DiscoverUiState()
/**
* Clustering complete, ready for user to name people
*/
data class NamingReady( data class NamingReady(
val result: ClusteringResult val result: ClusteringResult
) : DiscoverUiState() ) : 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( data class NamingCluster(
val result: ClusteringResult, val result: ClusteringResult,
val selectedCluster: FaceCluster, val selectedCluster: FaceCluster,
val qualityResult: ClusterQualityResult,
val suggestedSiblings: List<FaceCluster> val suggestedSiblings: List<FaceCluster>
) : DiscoverUiState() ) : DiscoverUiState()
/** object AnalyzingCluster : DiscoverUiState()
* Training in progress
*/
data class Training( data class Training(
val stage: String, val stage: String,
val progress: Int, val progress: Int,
val total: Int val total: Int
) : DiscoverUiState() ) : DiscoverUiState()
/**
* STAGE 2: Validation scan complete - show preview to user
*/
data class ValidationPreview( data class ValidationPreview(
val personId: String, val personId: String,
val personName: String, val personName: String,
val validationResult: ValidationScanResult, val validationResult: ValidationScanResult
val originalClusterResult: ClusteringResult
) : DiscoverUiState() ) : DiscoverUiState()
/**
* All clusters named and scans launched
*/
data class Complete( data class Complete(
val message: String val message: String
) : DiscoverUiState() ) : DiscoverUiState()
/**
* No people found in library
*/
data class NoPeopleFound( data class NoPeopleFound(
val message: String val message: String
) : DiscoverUiState() ) : DiscoverUiState()
/**
* Error occurred
*/
data class Error( data class Error(
val message: String val message: String
) : DiscoverUiState() ) : DiscoverUiState()

View File

@@ -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")
}
}
)
}

View File

@@ -8,6 +8,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.placeholder.sherpai2.ui.navigation.AppNavHost import com.placeholder.sherpai2.ui.navigation.AppNavHost
@@ -15,14 +16,16 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes
import kotlinx.coroutines.launch 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 * NEW: Prompts user to populate face cache on app launch if needed
* from MainScreen's TopAppBar to prevent ugly double headers. * FIXED: Prevents double headers for screens with their own TopAppBar
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen() { fun MainScreen(
mainViewModel: MainViewModel = hiltViewModel() // Same package - no import needed!
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
@@ -30,6 +33,25 @@ fun MainScreen() {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: AppRoutes.SEARCH 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
@@ -51,9 +73,13 @@ fun MainScreen() {
// CRITICAL: Some screens manage their own TopAppBar // CRITICAL: Some screens manage their own TopAppBar
// Hide MainScreen's TopAppBar for these routes to prevent double headers // Hide MainScreen's TopAppBar for these routes to prevent double headers
val screensWithOwnTopBar = setOf( 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( Scaffold(
topBar = { topBar = {

View File

@@ -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<Boolean> = _needsFaceCachePopulation.asStateFlow()
private val _unscannedPhotoCount = MutableStateFlow(0)
val unscannedPhotoCount: StateFlow<Int> = _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()
}
}

View File

@@ -71,6 +71,8 @@ fun PhotoUtilitiesScreen(
ToolsTabContent( ToolsTabContent(
uiState = uiState, uiState = uiState,
scanProgress = scanProgress, scanProgress = scanProgress,
onPopulateFaceCache = { viewModel.populateFaceCache() },
onForceRebuildCache = { viewModel.forceRebuildFaceCache() },
onScanPhotos = { viewModel.scanForPhotos() }, onScanPhotos = { viewModel.scanForPhotos() },
onDetectDuplicates = { viewModel.detectDuplicates() }, onDetectDuplicates = { viewModel.detectDuplicates() },
onDetectBursts = { viewModel.detectBursts() }, onDetectBursts = { viewModel.detectBursts() },
@@ -85,6 +87,8 @@ fun PhotoUtilitiesScreen(
private fun ToolsTabContent( private fun ToolsTabContent(
uiState: UtilitiesUiState, uiState: UtilitiesUiState,
scanProgress: ScanProgress?, scanProgress: ScanProgress?,
onPopulateFaceCache: () -> Unit,
onForceRebuildCache: () -> Unit,
onScanPhotos: () -> Unit, onScanPhotos: () -> Unit,
onDetectDuplicates: () -> Unit, onDetectDuplicates: () -> Unit,
onDetectBursts: () -> Unit, onDetectBursts: () -> Unit,
@@ -96,8 +100,39 @@ private fun ToolsTabContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(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 // Section: Scan & Import
item { item {
Spacer(Modifier.height(8.dp))
SectionHeader( SectionHeader(
title = "Scan & Import", title = "Scan & Import",
icon = Icons.Default.Scanner icon = Icons.Default.Scanner

View File

@@ -40,7 +40,8 @@ class PhotoUtilitiesViewModel @Inject constructor(
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val imageDao: ImageDao, private val imageDao: ImageDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val imageTagDao: ImageTagDao private val imageTagDao: ImageTagDao,
private val populateFaceDetectionCacheUseCase: com.placeholder.sherpai2.domain.usecase.PopulateFaceDetectionCacheUseCase
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<UtilitiesUiState>(UtilitiesUiState.Idle) private val _uiState = MutableStateFlow<UtilitiesUiState>(UtilitiesUiState.Idle)
@@ -49,6 +50,112 @@ class PhotoUtilitiesViewModel @Inject constructor(
private val _scanProgress = MutableStateFlow<ScanProgress?>(null) private val _scanProgress = MutableStateFlow<ScanProgress?>(null)
val scanProgress: StateFlow<ScanProgress?> = _scanProgress.asStateFlow() val scanProgress: StateFlow<ScanProgress?> = _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 * Manual scan for new photos
*/ */