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
*
* 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()
*/

View File

@@ -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
// ==========================================

View File

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

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

View File

@@ -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>(DiscoverUiState.Idle)
val uiState: StateFlow<DiscoverUiState> = _uiState.asStateFlow()
// Track which clusters have been named
private val namedClusterIds = mutableSetOf<Int>()
// 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}"
)
}
}
}
}
/**
* 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."
"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<FaceCluster>
) : 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()

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.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 = {

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(
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

View File

@@ -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>(UtilitiesUiState.Idle)
@@ -49,6 +50,112 @@ class PhotoUtilitiesViewModel @Inject constructor(
private val _scanProgress = MutableStateFlow<ScanProgress?>(null)
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
*/