puasemid oh god
This commit is contained in:
@@ -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()
|
||||
*/
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
@@ -48,4 +48,4 @@ interface PersonDao {
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM persons WHERE id = :personId)")
|
||||
suspend fun personExists(personId: String): Boolean
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
_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<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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user