puasemid oh god
This commit is contained in:
@@ -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()
|
||||||
*/
|
*/
|
||||||
@@ -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
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 =====
|
// ===== 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)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.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 = {
|
||||||
|
|||||||
@@ -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(
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user